From c2106a4b948908eab0497f151da7652786e4e71a Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Thu, 12 Mar 2026 18:42:47 -0700 Subject: [PATCH 1/3] secrets injection core --- Cargo.toml | 1 + docs/design/multi-scope-mandates.md | 329 +++++++++++++++++++++++++++ docs/sidecar-user-manual.md | 189 +++++++++++++++- policies/README.md | 72 ++++++ policies/secret-injection.json | 117 ++++++++++ policies/yaml/README.md | 62 +++++ policies/yaml/permissive.yaml | 99 ++++++++ policies/yaml/read-only.yaml | 301 +++++++++++++++++++++++++ policies/yaml/secret-injection.yaml | 134 +++++++++++ policies/yaml/strict.yaml | 337 ++++++++++++++++++++++++++++ src/http/execute.rs | 181 ++++++++++++--- src/http/mod.rs | 8 + src/lib.rs | 1 + src/models/mod.rs | 8 + src/policy/mod.rs | 27 +++ src/secrets.rs | 291 ++++++++++++++++++++++++ tests/integration_test.rs | 18 ++ 17 files changed, 2138 insertions(+), 37 deletions(-) create mode 100644 docs/design/multi-scope-mandates.md create mode 100644 policies/secret-injection.json create mode 100644 policies/yaml/README.md create mode 100644 policies/yaml/permissive.yaml create mode 100644 policies/yaml/read-only.yaml create mode 100644 policies/yaml/secret-injection.yaml create mode 100644 policies/yaml/strict.yaml create mode 100644 src/secrets.rs diff --git a/Cargo.toml b/Cargo.toml index c3fbe9b..78fad9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ lru = "0.12" # Pattern matching (for policy rules) glob = "0.3" +regex = "1" # CLI clap = { version = "4", features = ["derive", "env"] } diff --git a/docs/design/multi-scope-mandates.md b/docs/design/multi-scope-mandates.md new file mode 100644 index 0000000..79e60a0 --- /dev/null +++ b/docs/design/multi-scope-mandates.md @@ -0,0 +1,329 @@ +# Multi-Scope Mandate Support + +**Status**: Implemented +**Author**: Claude +**Date**: 2026-03-08 +**Version**: 1.0 + +## Problem Statement + +The current mandate model supports only a single `action` and `resource` pair per mandate. This creates problems for orchestrator agents that need to delegate multiple types of operations to child agents. + +### Current Workaround + +Orchestrators must request **separate root mandates** for each scope they intend to delegate: + +```python +# Browser scope mandate +browser_mandate = await client.authorize_root( + principal="agent:orchestrator", + action="browser.*", + resource="https://www.amazon.com/*", +) + +# Filesystem scope mandate +fs_mandate = await client.authorize_root( + principal="agent:orchestrator", + action="fs.*", + resource="**/workspace/data/**", +) +``` + +### Problems with Current Approach + +1. **No unified audit trail** - Multiple mandate chains for what's logically one orchestration +2. **Cascade revocation doesn't work** - Revoking one mandate doesn't revoke related delegations +3. **Multiple auth requests** - N scopes = N authorization calls (latency, overhead) +4. **Scope tracking complexity** - Orchestrator must track which mandate to use for each delegation +5. **Policy fragmentation** - Hard to express "orchestrator can do browser+fs" as single policy rule + +## Proposed Solution + +### Option A: Multi-Scope ActionSpec (Recommended) + +Extend `ActionSpec` to support arrays of action/resource pairs: + +```rust +// Current (single scope) +pub struct ActionSpec { + pub action: String, + pub resource: String, + pub intent: String, +} + +// Proposed (multi-scope) +pub struct ActionSpec { + pub scopes: Vec, // One or more scopes + pub intent: String, +} + +pub struct ScopeSpec { + pub action: String, + pub resource: String, +} +``` + +#### Wire Format + +```json +{ + "principal": "agent:orchestrator", + "scopes": [ + { "action": "browser.*", "resource": "https://www.amazon.com/*" }, + { "action": "fs.*", "resource": "**/workspace/data/**" } + ], + "intent_hash": "orchestrate:ecommerce:run-123" +} +``` + +#### Scope Narrowing for Delegation + +When delegating from a multi-scope parent, the child scope must be a subset of **at least one** parent scope (OR semantics): + +``` +Parent: [browser.*, fs.*] +Child request: browser.navigate → ALLOWED (matches browser.*) +Child request: fs.write → ALLOWED (matches fs.*) +Child request: network.* → DENIED (matches none) +``` + +### Option B: Scope Bundle Token + +Alternative: Keep single-scope mandates but issue a "bundle token" that references multiple mandates: + +```json +{ + "bundle_id": "bundle-abc123", + "mandate_tokens": ["mandate-browser-xyz", "mandate-fs-xyz"], + "unified_revocation": true +} +``` + +**Pros**: Backward compatible, simpler initial implementation +**Cons**: Still multiple mandates internally, complex revocation logic + +### Recommendation + +**Option A (Multi-Scope ActionSpec)** is cleaner long-term: +- Single mandate = single audit entry +- Natural cascade revocation +- Simpler mental model for orchestrators +- Better aligns with "one task = one authorization" + +## Implementation Status + +### Phase 1: Sidecar Changes ✅ COMPLETE + +1. **Extended `ActionSpec` model** (`src/models/mod.rs`) + - Added `ScopeSpec` struct with `action` and `resource` fields + - Modified `ActionSpec` to include `scopes: Vec` + - Added helper methods: `ActionSpec::single()`, `ActionSpec::multi()`, `all_scopes()`, `is_multi_scope()` + - Backward compatible: single action/resource still works + +2. **Updated `MandateClaims`** (`src/models/mod.rs`) + - Added `scopes: Vec` field to JWT claims + - Added `all_scopes()` and `is_multi_scope()` helper methods + +3. **Updated authorization logic** (`src/http/mod.rs`) + - `authorize_handler` now evaluates each scope against policy rules + - All scopes must be allowed for request to succeed + - Returns `scopes_authorized` array in response + +4. **Updated mandate issuance** (`src/mandate/mod.rs`) + - `LocalMandateSigner::issue()` encodes all scopes in JWT claims + +5. **Updated delegation validation** (`src/http/delegate.rs`, `src/policy/subset.rs`) + - Added `is_scope_subset_of_any()` for multi-scope parent validation + - Child scope must match at least one parent scope (OR semantics) + +6. **Updated HTTP request/response** (`src/models/mod.rs`) + - `SidecarAuthorizeRequest` accepts `scopes` array + - `SidecarAuthorizeResponse` includes `scopes_authorized` array + +### Phase 2: SDK Changes (TODO) + +1. **Python SDK** (`sdk-python`) + - `authorize()` accepts `scopes: List[Dict]` parameter + - Backward compatible: `action`/`resource` params still work + +2. **TypeScript SDK** (`sdk-ts`) + - Same pattern as Python + +### Phase 3: Demo Updates (TODO) + +1. **CrewAI E-commerce Demo** (`predicate-secure/examples/crewai-ecommerce-demo`) + - Update `main.py` to use single multi-scope authorization + - Remove workaround of multiple root mandates + +## API Changes + +### `/v1/authorize` Request + +**Current (backward compatible)**: +```json +{ + "principal": "agent:orchestrator", + "action": "browser.*", + "resource": "https://example.com/*" +} +``` + +**New multi-scope**: +```json +{ + "principal": "agent:orchestrator", + "scopes": [ + { "action": "browser.*", "resource": "https://example.com/*" }, + { "action": "fs.*", "resource": "**/workspace/**" } + ], + "intent_hash": "orchestrate:run-123" +} +``` + +### `/v1/authorize` Response + +```json +{ + "allowed": true, + "reason": "all scopes authorized", + "mandate_id": "mandate-abc123", + "mandate_token": "eyJ...", + "scopes_authorized": [ + { "action": "browser.*", "resource": "https://example.com/*", "matched_rule": "allow-browser" }, + { "action": "fs.*", "resource": "**/workspace/**", "matched_rule": "allow-fs" } + ] +} +``` + +### `/v1/delegate` Request + +No change - child requests single scope, validated against parent's multi-scope mandate. + +## Backward Compatibility + +1. **Single scope requests**: Continue to work unchanged +2. **Existing mandates**: Single-scope mandates remain valid +3. **SDK versions**: Old SDKs work with single-scope; new SDKs support both +4. **Wire format**: `action`/`resource` fields deprecated but supported + +## Migration Path + +1. Release sidecar with multi-scope support (backward compatible) +2. Update SDKs with optional `scopes` parameter +3. Update demos to use multi-scope where beneficial +4. Deprecation warnings for single-scope in future version + +--- + +## Changes Required in CrewAI Demo + +Once multi-scope mandates are implemented, the CrewAI e-commerce demo should be updated: + +### Current Workaround (`main.py`) + +```python +# Current: Multiple separate root mandates (workaround) +browser_root_mandate = await _delegation_client.authorize_root( + principal="agent:orchestrator", + action="browser.*", + resource="https://www.amazon.com/*", + intent_hash=f"orchestrate:browser:{run_id}", +) + +fs_root_mandate = await _delegation_client.authorize_root( + principal="agent:orchestrator", + action="fs.*", + resource="**/workspace/data/**", + intent_hash=f"orchestrate:fs:{run_id}", +) + +# Must track which mandate to use for which delegation +scraper_mandate = await _delegation_client.delegate( + parent_mandate_token=browser_root_mandate.mandate_token, + child_principal="agent:scraper", + ... +) + +analyst_mandate = await _delegation_client.delegate( + parent_mandate_token=fs_root_mandate.mandate_token, # Different parent! + child_principal="agent:analyst", + ... +) +``` + +### Future: Multi-Scope Implementation + +```python +# Future: Single multi-scope root mandate +orchestrator_mandate = await _delegation_client.authorize_root( + principal="agent:orchestrator", + scopes=[ + {"action": "browser.*", "resource": "https://www.amazon.com/*"}, + {"action": "fs.*", "resource": "**/workspace/data/**"}, + ], + intent_hash=f"orchestrate:ecommerce:{run_id}", +) + +# Single mandate used for all delegations +scraper_mandate = await _delegation_client.delegate( + parent_mandate_token=orchestrator_mandate.mandate_token, # Same parent + child_principal="agent:scraper", + action="browser.navigate", + resource="https://www.amazon.com/s?k=laptop", +) + +analyst_mandate = await _delegation_client.delegate( + parent_mandate_token=orchestrator_mandate.mandate_token, # Same parent + child_principal="agent:analyst", + action="fs.write", + resource="/workspace/data/analysis.json", +) +``` + +### Demo Files to Update + +1. **`main.py`** + - Replace multiple `authorize_root()` calls with single multi-scope call + - Remove mandate tracking logic (no need to match mandate to scope) + - Update docstrings and comments + +2. **`delegation_client.py`** (if exists) + - Add `scopes` parameter to `authorize_root()` + - Keep backward compatibility with `action`/`resource` params + +3. **`policies/monitoring.yaml`** + - Update policy rules to allow multi-scope authorization + - Example: + ```yaml + rules: + - name: allow-orchestrator-multi-scope + principal: "agent:orchestrator" + actions: ["browser.*", "fs.*"] # Multiple actions + resources: ["https://*.amazon.com/*", "**/workspace/**"] + effect: allow + ``` + +4. **`README.md`** + - Document multi-scope authorization usage + - Update architecture diagram showing single mandate chain + +### Benefits for Demo + +- **Cleaner code**: One mandate, one delegation chain +- **Better observability**: Single audit trail per orchestration run +- **Simpler error handling**: One authorization to check, not N +- **Proper revocation**: Revoking orchestrator mandate revokes all child delegations + +--- + +## Open Questions + +1. **Scope limit**: Should there be a max number of scopes per mandate? (Suggested: 10) +2. **Partial authorization**: If 2/3 scopes allowed, should we issue partial mandate or deny? +3. **Scope intersection**: Can child request scope that spans multiple parent scopes? + +## References + +- [Chain Delegation Documentation](./sidecar-user-manual.md#delegation-chains) +- [CrewAI Demo](../../predicate-secure/examples/crewai-ecommerce-demo/) +- [Scope Narrowing Rules](../src/delegation/scope.rs) diff --git a/docs/sidecar-user-manual.md b/docs/sidecar-user-manual.md index e88351d..032c65b 100644 --- a/docs/sidecar-user-manual.md +++ b/docs/sidecar-user-manual.md @@ -17,7 +17,8 @@ A comprehensive guide to installing, configuring, and operating the Predicate Au 9. [Terminal Dashboard](#terminal-dashboard) 10. [Delegation Chains](#delegation-chains) 11. [Security Features (Phase 5)](#security-features-phase-5) -12. [Troubleshooting](#troubleshooting) +12. [Secret Injection](#secret-injection) +13. [Troubleshooting](#troubleshooting) --- @@ -1112,6 +1113,192 @@ curl http://127.0.0.1:8787/status --- +## Secret Injection + +The sidecar supports policy-driven secret injection for `http.fetch` and `cli.exec` actions. Secrets are injected at execution time, ensuring agents never see raw credentials. + +### How It Works + +1. Store secrets as environment variables on the machine running the sidecar +2. Reference them in policy rules using `${VAR_NAME}` syntax +3. When an action matches a rule with injection config, the sidecar substitutes values at runtime +4. The agent receives only the action result—never the secret values + +``` +┌─────────┐ authorize ┌──────────────┐ execute ┌─────────┐ +│ Agent │ ─────────────────▶│ Sidecar │ ────────────────▶│ Backend │ +│ │ (no secrets) │ inject: $KEY │ (with secrets) │ API │ +└─────────┘ └──────────────┘ └─────────┘ +``` + +### Environment Variable Syntax + +| Syntax | Description | +|--------|-------------| +| `${VAR_NAME}` | Substitute with value of VAR_NAME (error if not set) | +| `${VAR_NAME:-default}` | Substitute with value or use default if not set | + +### Policy Configuration + +#### Injecting Headers for HTTP Requests + +Use `inject_headers` to add authentication headers to `http.fetch` actions: + +```yaml +rules: + - name: api-with-auth + effect: allow + principals: ["agent:*"] + actions: ["http.fetch"] + resources: ["https://api.example.com/*"] + inject_headers: + Authorization: "Bearer ${API_TOKEN}" + X-Api-Key: "${API_KEY}" +``` + +JSON equivalent: + +```json +{ + "rules": [ + { + "name": "api-with-auth", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.fetch"], + "resources": ["https://api.example.com/*"], + "inject_headers": { + "Authorization": "Bearer ${API_TOKEN}", + "X-Api-Key": "${API_KEY}" + } + } + ] +} +``` + +#### Injecting Environment Variables for CLI Commands + +Use `inject_env` to pass secrets to `cli.exec` actions: + +```yaml +rules: + - name: deploy-with-credentials + effect: allow + principals: ["agent:deployer"] + actions: ["cli.exec"] + resources: ["deploy.sh", "kubectl"] + inject_env: + AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID}" + AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY}" + KUBECONFIG: "${KUBECONFIG:-/etc/kubernetes/admin.conf}" +``` + +### Complete Example + +**Policy file (`policy-with-secrets.yaml`):** + +```yaml +rules: + # Allow API calls with injected auth header + - name: github-api + effect: allow + principals: ["agent:*"] + actions: ["http.fetch"] + resources: ["https://api.github.com/*"] + inject_headers: + Authorization: "Bearer ${GITHUB_TOKEN}" + Accept: "application/vnd.github.v3+json" + + # Allow database CLI with credentials + - name: database-cli + effect: allow + principals: ["agent:dba"] + actions: ["cli.exec"] + resources: ["psql", "pg_dump"] + inject_env: + PGPASSWORD: "${DB_PASSWORD}" + PGHOST: "${DB_HOST:-localhost}" + PGUSER: "${DB_USER:-postgres}" + + # Allow cloud CLI operations + - name: aws-cli + effect: allow + principals: ["agent:ops"] + actions: ["cli.exec"] + resources: ["aws"] + inject_env: + AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID}" + AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY}" + AWS_DEFAULT_REGION: "${AWS_REGION:-us-east-1}" +``` + +**Starting the sidecar:** + +```bash +# Set secrets as environment variables +export GITHUB_TOKEN="ghp_xxxxxxxxxxxx" +export DB_PASSWORD="secure-password" +export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE" +export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + +# Start sidecar +./predicate-authorityd --policy-file policy-with-secrets.yaml run +``` + +**Agent making a request:** + +```python +# Agent code - no secrets visible +response = await authority_client.execute( + action="http.fetch", + resource="https://api.github.com/user/repos", + parameters={"method": "GET"} +) +# The sidecar injected the Authorization header automatically +``` + +### Security Benefits + +- **Zero-trust execution**: Agents never see or handle raw secrets +- **Policy-driven**: Security team controls which secrets are injected where +- **Audit trail**: All injections are logged with the action (values redacted) +- **No agent changes**: Existing agents work without modification +- **Defense in depth**: Even compromised agents cannot exfiltrate secrets + +### Best Practices + +1. **Use specific resource patterns**: Don't inject secrets into broad `*` patterns +2. **Prefer defaults for non-sensitive values**: Use `${VAR:-default}` for regions, hosts, etc. +3. **Rotate secrets regularly**: The sidecar reads env vars at substitution time +4. **Monitor for missing vars**: The sidecar logs warnings when referenced vars are not set +5. **Test policies in audit mode**: Verify injection works before enabling enforcement + +### Troubleshooting + +#### "Environment variable not set" errors + +The sidecar logs a warning when a referenced variable is not set: + +``` +WARN: Environment variable 'API_TOKEN' not set, using empty string +``` + +**Fix:** Ensure all referenced variables are exported before starting the sidecar. + +#### Headers not being injected + +**Cause:** Action or resource doesn't match the rule pattern. + +**Fix:** Run with `--log-level debug` to see which rules are evaluated and matched. + +#### Default values not working + +**Cause:** Syntax error in default value format. + +**Fix:** Ensure correct syntax: `${VAR:-default}` (note the `:-` separator). + +--- + ## Related Documentation - [how-it-works.md](../how-it-works.md) - Architectural overview of IdP + Sidecar + Mandates diff --git a/policies/README.md b/policies/README.md index f772c3f..b07e182 100644 --- a/policies/README.md +++ b/policies/README.md @@ -31,6 +31,7 @@ export PREDICATE_POLICY_FILE=policies/strict.json | **[permissive.json](permissive.json)** | Development | Full access | Full access | Full access | Full access | | **[audit-only.json](audit-only.json)** | Agent profiling | Logged | Logged | Logged | Logged | | **[minimal.json](minimal.json)** | Starting point | BLOCKED | BLOCKED | BLOCKED | HTTPS only | +| **[secret-injection.json](secret-injection.json)** | API + CLI with secrets | Safe commands | With injected creds | API auth headers | BLOCKED | --- @@ -166,6 +167,31 @@ export PREDICATE_POLICY_FILE=policies/strict.json **Best for:** Starting point for building custom policies. +### secret-injection.json + +**Purpose:** Demonstrate policy-driven secret injection for API calls and CLI commands. + +**Features:** +- Auto-inject auth headers for GitHub, OpenAI, Anthropic APIs +- Auto-inject AWS/kubectl/database credentials for CLI commands +- Secrets stay on the sidecar, agent never sees them + +**Example rules:** +```json +{ + "name": "github-api-with-auth", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.fetch"], + "resources": ["https://api.github.com/*"], + "inject_headers": { + "Authorization": "Bearer ${GITHUB_TOKEN}" + } +} +``` + +**Best for:** Demonstrating zero-trust secret handling where agents cannot access raw credentials. + --- ## Policy Schema @@ -198,6 +224,52 @@ Each policy file contains a `rules` array. Rules are evaluated in order. | `resources` | Yes | string[] | ON WHAT resources | | `required_labels` | No | string[] | Verification labels required (default: `[]`) | | `max_delegation_depth` | No | number | Max delegation chain depth | +| `inject_headers` | No | object | Headers to inject for `http.fetch` actions | +| `inject_env` | No | object | Environment variables to inject for `cli.exec` actions | + +### Secret Injection + +Policy rules can specify headers or environment variables to inject when actions are executed through `/v1/execute`. Values support environment variable substitution: + +| Syntax | Description | +|--------|-------------| +| `${VAR_NAME}` | Substitute with environment variable value | +| `${VAR_NAME:-default}` | Use default if variable not set | + +**Example: Inject auth header for API calls** + +```json +{ + "name": "api-with-auth", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.fetch"], + "resources": ["https://api.example.com/*"], + "inject_headers": { + "Authorization": "Bearer ${API_TOKEN}", + "X-Api-Key": "${API_KEY}" + } +} +``` + +**Example: Inject credentials for CLI** + +```json +{ + "name": "aws-cli", + "effect": "allow", + "principals": ["agent:ops"], + "actions": ["cli.exec"], + "resources": ["aws", "aws *"], + "inject_env": { + "AWS_ACCESS_KEY_ID": "${AWS_ACCESS_KEY_ID}", + "AWS_SECRET_ACCESS_KEY": "${AWS_SECRET_ACCESS_KEY}", + "AWS_DEFAULT_REGION": "${AWS_REGION:-us-east-1}" + } +} +``` + +See the [sidecar user manual](../docs/sidecar-user-manual.md#secret-injection) for more details. ### Evaluation Order diff --git a/policies/secret-injection.json b/policies/secret-injection.json new file mode 100644 index 0000000..f26deba --- /dev/null +++ b/policies/secret-injection.json @@ -0,0 +1,117 @@ +{ + "rules": [ + { + "name": "deny-sensitive-endpoints-direct", + "effect": "deny", + "principals": ["*"], + "actions": ["http.fetch", "http.get", "http.post"], + "resources": [ + "*/internal/*", + "*/admin/*", + "*/private/*" + ] + }, + { + "name": "github-api-with-auth", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.fetch"], + "resources": ["https://api.github.com/*"], + "inject_headers": { + "Authorization": "Bearer ${GITHUB_TOKEN}", + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28" + } + }, + { + "name": "openai-api-with-auth", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.fetch"], + "resources": ["https://api.openai.com/*"], + "inject_headers": { + "Authorization": "Bearer ${OPENAI_API_KEY}", + "Content-Type": "application/json" + } + }, + { + "name": "anthropic-api-with-auth", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.fetch"], + "resources": ["https://api.anthropic.com/*"], + "inject_headers": { + "x-api-key": "${ANTHROPIC_API_KEY}", + "anthropic-version": "2023-06-01", + "Content-Type": "application/json" + } + }, + { + "name": "aws-cli-with-credentials", + "effect": "allow", + "principals": ["agent:ops", "agent:deployer"], + "actions": ["cli.exec"], + "resources": ["aws", "aws *"], + "inject_env": { + "AWS_ACCESS_KEY_ID": "${AWS_ACCESS_KEY_ID}", + "AWS_SECRET_ACCESS_KEY": "${AWS_SECRET_ACCESS_KEY}", + "AWS_DEFAULT_REGION": "${AWS_REGION:-us-east-1}" + } + }, + { + "name": "kubectl-with-config", + "effect": "allow", + "principals": ["agent:ops", "agent:deployer"], + "actions": ["cli.exec"], + "resources": ["kubectl", "kubectl *"], + "inject_env": { + "KUBECONFIG": "${KUBECONFIG:-/etc/kubernetes/admin.conf}" + } + }, + { + "name": "database-cli-with-credentials", + "effect": "allow", + "principals": ["agent:dba"], + "actions": ["cli.exec"], + "resources": ["psql", "psql *", "pg_dump", "pg_dump *", "mysql", "mysql *"], + "inject_env": { + "PGPASSWORD": "${DB_PASSWORD}", + "PGHOST": "${DB_HOST:-localhost}", + "PGUSER": "${DB_USER:-postgres}", + "PGDATABASE": "${DB_NAME:-postgres}" + } + }, + { + "name": "docker-registry-auth", + "effect": "allow", + "principals": ["agent:builder"], + "actions": ["cli.exec"], + "resources": ["docker push*", "docker pull*", "docker login*"], + "inject_env": { + "DOCKER_USERNAME": "${DOCKER_USERNAME}", + "DOCKER_PASSWORD": "${DOCKER_PASSWORD}", + "DOCKER_REGISTRY": "${DOCKER_REGISTRY:-docker.io}" + } + }, + { + "name": "allow-safe-cli-commands", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["cli.exec"], + "resources": [ + "ls", "ls *", + "cat *", "head *", "tail *", + "grep *", "find *", + "pwd", "whoami", "date", + "echo *" + ] + }, + { + "name": "default-deny", + "effect": "deny", + "principals": ["*"], + "actions": ["*"], + "resources": ["*"] + } + ] +} diff --git a/policies/yaml/README.md b/policies/yaml/README.md new file mode 100644 index 0000000..f5de587 --- /dev/null +++ b/policies/yaml/README.md @@ -0,0 +1,62 @@ +# YAML Policy Templates + +This directory contains YAML versions of the policy templates. YAML format offers: +- Better readability with comments +- Cleaner syntax for lists and nested structures +- Easier manual editing + +## Available Templates + +| Policy | Description | +|--------|-------------| +| [strict.yaml](strict.yaml) | Production default - workspace isolation, safe commands, HTTPS only | +| [read-only.yaml](read-only.yaml) | Code review - read-only access, no mutations | +| [permissive.yaml](permissive.yaml) | Development - minimal restrictions | +| [secret-injection.yaml](secret-injection.yaml) | API + CLI with automatic secret injection | + +## Usage + +```bash +# Start sidecar with YAML policy +./predicate-authorityd --policy-file policies/yaml/strict.yaml run + +# Or use environment variable +export PREDICATE_POLICY_FILE=policies/yaml/secret-injection.yaml +./predicate-authorityd run +``` + +## Format Auto-Detection + +The sidecar auto-detects format by file extension: +- `.yaml` or `.yml` → YAML format +- `.json` → JSON format + +## Secret Injection Example + +The `secret-injection.yaml` policy demonstrates how to inject secrets at execution time: + +```yaml +- name: github-api-with-auth + effect: allow + principals: ["agent:*"] + actions: ["http.fetch"] + resources: ["https://api.github.com/*"] + inject_headers: + Authorization: "Bearer ${GITHUB_TOKEN}" + Accept: "application/vnd.github.v3+json" +``` + +Set the environment variable before starting the sidecar: + +```bash +export GITHUB_TOKEN="ghp_xxxxxxxxxxxx" +./predicate-authorityd --policy-file policies/yaml/secret-injection.yaml run +``` + +The agent never sees the token - the sidecar injects it at execution time. + +## See Also + +- [JSON policies](../) - JSON versions of these policies +- [Policy README](../README.md) - Full policy documentation +- [Sidecar User Manual](../../docs/sidecar-user-manual.md) - Complete sidecar documentation diff --git a/policies/yaml/permissive.yaml b/policies/yaml/permissive.yaml new file mode 100644 index 0000000..ed6f2e3 --- /dev/null +++ b/policies/yaml/permissive.yaml @@ -0,0 +1,99 @@ +# Permissive Policy +# Minimal restrictions for trusted development environments +# WARNING: Use only in trusted environments with fully trusted agents + +rules: + # === DENY RULES (only catastrophic commands) === + + - name: block-catastrophic-commands + effect: deny + principals: ["*"] + actions: + - shell.exec + - bash + - exec + - run + resources: + # System destruction + - "rm -rf /*" + - "rm -rf /" + - "rm -rf ~" + # Fork bomb + - ":(){ :|:& };:" + # Disk destruction + - "dd if=/dev/zero of=/dev/*" + - "mkfs.*" + - "> /dev/sd*" + - "> /dev/nvme*" + + - name: block-sensitive-credentials + effect: deny + principals: ["*"] + actions: + - fs.read + - fs.write + - file.read + - file.write + - read + - write + resources: + # SSH private keys + - "*/.ssh/id_*" + # Cloud credentials + - "*/.aws/credentials" + - "*/.azure/credentials*" + - "*/.config/gcloud/credentials*" + # System shadow file + - "/etc/shadow" + + # === ALLOW RULES (broad access) === + + - name: allow-all-filesystem + effect: allow + principals: ["agent:*"] + actions: + - fs.* + - file.* + - read + - write + - edit + - delete + - glob + - grep + resources: ["*"] + + - name: allow-all-shell + effect: allow + principals: ["agent:*"] + actions: + - shell.exec + - bash + - exec + - run + - os.* + - process.* + - system.* + resources: ["*"] + + - name: allow-all-http + effect: allow + principals: ["agent:*"] + actions: + - http.* + - fetch + resources: ["*"] + + - name: allow-all-browser + effect: allow + principals: ["agent:*"] + actions: + - browser.* + resources: ["*"] + + # === DEFAULT DENY === + + - name: default-deny + effect: deny + principals: ["*"] + actions: ["*"] + resources: ["*"] diff --git a/policies/yaml/read-only.yaml b/policies/yaml/read-only.yaml new file mode 100644 index 0000000..ee3ecc3 --- /dev/null +++ b/policies/yaml/read-only.yaml @@ -0,0 +1,301 @@ +# Read-Only Policy +# Allow context gathering without modification capabilities +# Best for: Code review agents, documentation generators, codebase analysis + +rules: + # === DENY RULES (evaluated first) === + + - name: block-filesystem-write + effect: deny + principals: ["*"] + actions: + - fs.write + - fs.write_file + - fs.create + - fs.append + - file.write + - write + - edit + resources: ["*"] + + - name: block-filesystem-delete + effect: deny + principals: ["*"] + actions: + - fs.delete + - fs.remove + - fs.unlink + - fs.rmdir + - fs.rm + - file.delete + - delete + resources: ["*"] + + - name: block-filesystem-modify + effect: deny + principals: ["*"] + actions: + - fs.rename + - fs.move + - fs.copy + - fs.mkdir + - fs.chmod + - fs.chown + - fs.truncate + - file.rename + - file.move + resources: ["*"] + + - name: block-sensitive-file-read + effect: deny + principals: ["*"] + actions: + - fs.read + - fs.read_file + - file.read + - read + resources: + # System files + - "/etc/passwd" + - "/etc/shadow" + - "/etc/sudoers" + - "/etc/ssh/*" + # SSH keys + - "*/.ssh/id_*" + - "*/.ssh/known_hosts" + - "*/.ssh/authorized_keys" + # Cloud credentials + - "*/.aws/credentials" + - "*/.aws/config" + - "*/.config/gcloud/*" + - "*/.azure/*" + - "*/.kube/config" + # Package manager auth + - "*/.npmrc" + - "*/.pypirc" + - "*/.netrc" + # Environment files + - "*/.env" + - "*/.env.*" + - "**/.env" + - "**/.env.*" + # Secrets and keys + - "**/credentials*" + - "**/secrets*" + - "**/*.pem" + - "**/*.key" + - "**/id_rsa*" + - "**/id_ed25519*" + + - name: block-dangerous-shell-commands + effect: deny + principals: ["*"] + actions: + - shell.exec + - bash + - exec + - run + resources: + # File modification + - "rm *" + - "rm -rf *" + - "rmdir *" + - "unlink *" + - "shred *" + - "mv *" + - "cp *" + - "mkdir *" + - "touch *" + - "chmod *" + - "chown *" + - "chgrp *" + # Privilege escalation + - "sudo *" + - "su *" + - "doas *" + - "pkexec *" + # Package managers + - "apt *" + - "apt-get *" + - "yum *" + - "dnf *" + - "pacman *" + - "brew *" + - "npm *" + - "pip *" + - "pip3 *" + # Remote code execution + - "curl * | bash*" + - "curl * | sh*" + - "wget * | bash*" + - "wget * | sh*" + # Git mutations + - "git push*" + - "git commit*" + - "git reset*" + - "git checkout*" + - "git branch -d*" + - "git branch -D*" + # Editors (interactive) + - "vi *" + - "vim *" + - "nano *" + - "emacs *" + # In-place edits + - "sed -i*" + - "awk -i*" + + - name: block-http-mutations + effect: deny + principals: ["*"] + actions: + - http.post + - http.put + - http.patch + - http.delete + resources: ["*"] + + - name: block-browser-mutations + effect: deny + principals: ["*"] + actions: + - browser.click + - browser.type + - browser.fill + - browser.submit + resources: ["*"] + + # === ALLOW RULES === + + - name: allow-filesystem-read + effect: allow + principals: ["agent:*"] + actions: + - fs.read + - fs.read_file + - fs.list + - fs.readdir + - fs.stat + - fs.exists + - fs.access + - file.read + - read + resources: ["*"] + + - name: allow-filesystem-search + effect: allow + principals: ["agent:*"] + actions: + - fs.glob + - fs.find + - fs.search + - glob + - grep + resources: ["*"] + + - name: allow-readonly-shell-commands + effect: allow + principals: ["agent:*"] + actions: + - shell.exec + - bash + - exec + - run + resources: + # File viewing + - "cat *" + - "head *" + - "tail *" + - "less *" + - "more *" + - "bat *" + # Directory listing + - "ls *" + - "ls" + - "ll *" + - "tree *" + - "exa *" + # File search + - "find *" + - "locate *" + - "which *" + - "whereis *" + - "type *" + # Content search + - "grep *" + - "rg *" + - "ag *" + - "ack *" + # Text processing (read-only) + - "wc *" + - "sort *" + - "uniq *" + - "cut *" + - "awk *" + - "sed *" + - "jq *" + - "yq *" + # System info + - "pwd" + - "whoami" + - "hostname" + - "uname *" + - "date" + - "uptime" + - "df *" + - "du *" + - "free *" + - "top -b*" + - "ps *" + - "env" + - "printenv*" + # Git (read-only operations) + - "git status*" + - "git log*" + - "git diff*" + - "git show*" + - "git branch*" + - "git remote*" + - "git rev-parse*" + - "git ls-files*" + - "git blame*" + # Code metrics + - "wc -l*" + - "cloc *" + - "tokei *" + - "file *" + - "stat *" + + - name: allow-https-get-requests + effect: allow + principals: ["agent:*"] + actions: + - http.get + - http.request + - fetch + - browser.navigate + - browser.goto + resources: ["https://*"] + + - name: allow-browser-read-operations + effect: allow + principals: ["agent:*"] + actions: + - browser.snapshot + - browser.screenshot + - browser.read + - browser.extract + - browser.get_text + - browser.get_html + - browser.get_state + - browser.scroll + - browser.wait + resources: ["*"] + + # === DEFAULT DENY === + + - name: default-deny + effect: deny + principals: ["*"] + actions: ["*"] + resources: ["*"] diff --git a/policies/yaml/secret-injection.yaml b/policies/yaml/secret-injection.yaml new file mode 100644 index 0000000..58f52f5 --- /dev/null +++ b/policies/yaml/secret-injection.yaml @@ -0,0 +1,134 @@ +# Secret Injection Policy +# Demonstrates policy-driven secret injection for API calls and CLI commands +# Secrets stay on the sidecar - agents never see raw credentials + +rules: + # === DENY RULES === + + - name: deny-sensitive-endpoints-direct + effect: deny + principals: ["*"] + actions: + - http.fetch + - http.get + - http.post + resources: + - "*/internal/*" + - "*/admin/*" + - "*/private/*" + + # === API RULES WITH HEADER INJECTION === + + - name: github-api-with-auth + effect: allow + principals: ["agent:*"] + actions: ["http.fetch"] + resources: ["https://api.github.com/*"] + inject_headers: + Authorization: "Bearer ${GITHUB_TOKEN}" + Accept: "application/vnd.github.v3+json" + X-GitHub-Api-Version: "2022-11-28" + + - name: openai-api-with-auth + effect: allow + principals: ["agent:*"] + actions: ["http.fetch"] + resources: ["https://api.openai.com/*"] + inject_headers: + Authorization: "Bearer ${OPENAI_API_KEY}" + Content-Type: "application/json" + + - name: anthropic-api-with-auth + effect: allow + principals: ["agent:*"] + actions: ["http.fetch"] + resources: ["https://api.anthropic.com/*"] + inject_headers: + x-api-key: "${ANTHROPIC_API_KEY}" + anthropic-version: "2023-06-01" + Content-Type: "application/json" + + # === CLI RULES WITH ENV INJECTION === + + - name: aws-cli-with-credentials + effect: allow + principals: + - "agent:ops" + - "agent:deployer" + actions: ["cli.exec"] + resources: + - "aws" + - "aws *" + inject_env: + AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID}" + AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY}" + AWS_DEFAULT_REGION: "${AWS_REGION:-us-east-1}" + + - name: kubectl-with-config + effect: allow + principals: + - "agent:ops" + - "agent:deployer" + actions: ["cli.exec"] + resources: + - "kubectl" + - "kubectl *" + inject_env: + KUBECONFIG: "${KUBECONFIG:-/etc/kubernetes/admin.conf}" + + - name: database-cli-with-credentials + effect: allow + principals: ["agent:dba"] + actions: ["cli.exec"] + resources: + - "psql" + - "psql *" + - "pg_dump" + - "pg_dump *" + - "mysql" + - "mysql *" + inject_env: + PGPASSWORD: "${DB_PASSWORD}" + PGHOST: "${DB_HOST:-localhost}" + PGUSER: "${DB_USER:-postgres}" + PGDATABASE: "${DB_NAME:-postgres}" + + - name: docker-registry-auth + effect: allow + principals: ["agent:builder"] + actions: ["cli.exec"] + resources: + - "docker push*" + - "docker pull*" + - "docker login*" + inject_env: + DOCKER_USERNAME: "${DOCKER_USERNAME}" + DOCKER_PASSWORD: "${DOCKER_PASSWORD}" + DOCKER_REGISTRY: "${DOCKER_REGISTRY:-docker.io}" + + # === SAFE CLI COMMANDS (no injection needed) === + + - name: allow-safe-cli-commands + effect: allow + principals: ["agent:*"] + actions: ["cli.exec"] + resources: + - "ls" + - "ls *" + - "cat *" + - "head *" + - "tail *" + - "grep *" + - "find *" + - "pwd" + - "whoami" + - "date" + - "echo *" + + # === DEFAULT DENY === + + - name: default-deny + effect: deny + principals: ["*"] + actions: ["*"] + resources: ["*"] diff --git a/policies/yaml/strict.yaml b/policies/yaml/strict.yaml new file mode 100644 index 0000000..c05b3f1 --- /dev/null +++ b/policies/yaml/strict.yaml @@ -0,0 +1,337 @@ +# Strict Policy - Recommended Production Default +# Balanced security with workspace isolation and safe command allowlists + +rules: + # === DENY RULES (evaluated first) === + + - name: block-sensitive-files + effect: deny + principals: ["*"] + actions: + - fs.read + - fs.write + - fs.delete + - fs.* + - file.* + - read + - write + - edit + resources: + # Environment files + - "**/.env" + - "**/.env.*" + # Credentials and secrets + - "**/credentials*" + - "**/secrets*" + # Certificates and keys + - "**/*.pem" + - "**/*.key" + - "**/*.p12" + - "**/*.pfx" + # SSH + - "*/.ssh/*" + - "/etc/ssh/*" + # Cloud provider configs + - "*/.aws/*" + - "*/.azure/*" + - "*/.config/gcloud/*" + - "*/.kube/config" + # Package manager auth + - "*/.npmrc" + - "*/.pypirc" + - "*/.netrc" + - "*/.docker/config.json" + # System files + - "/etc/passwd" + - "/etc/shadow" + - "/etc/sudoers" + - "/etc/sudoers.d/*" + + - name: block-outside-workspace + effect: deny + principals: ["*"] + actions: + - fs.write + - fs.delete + - fs.create + - fs.mkdir + - fs.rmdir + - fs.rename + - file.write + - file.delete + - write + - edit + - delete + resources: + # System directories + - "/etc/*" + - "/usr/*" + - "/bin/*" + - "/sbin/*" + - "/var/*" + - "/tmp/*" + # macOS system + - "/System/*" + - "/Library/*" + - "/Applications/*" + # Windows system + - "C:\\Windows\\*" + - "C:\\Program Files*" + + - name: block-dangerous-commands + effect: deny + principals: ["*"] + actions: + - shell.exec + - bash + - exec + - run + resources: + # Destructive commands + - "rm -rf /*" + - "rm -rf /" + - "rm -rf ~/*" + - ":(){ :|:& };:*" + - "dd if=/dev/zero*" + - "mkfs*" + - "> /dev/sd*" + # Privilege escalation + - "sudo *" + - "su *" + - "doas *" + - "pkexec *" + - "runas *" + # Permission changes + - "chmod 777*" + - "chmod -R 777*" + - "chown root*" + # Remote code execution + - "curl * | bash*" + - "curl * | sh*" + - "wget * | bash*" + - "wget * | sh*" + # Network backdoors + - "nc -l*" + - "ncat -l*" + # Secret exfiltration + - "*cat*id_rsa*" + - "*cat*.pem*" + - "echo $*_KEY*" + - "echo $*_SECRET*" + - "echo $*_TOKEN*" + - "printenv*KEY*" + - "printenv*SECRET*" + + - name: block-insecure-protocols + effect: deny + principals: ["*"] + actions: + - http.request + - http.get + - http.post + - fetch + - browser.navigate + resources: + - "http://*" + - "ftp://*" + - "file://*" + - "data:text/html*" + - "javascript:*" + + # === ALLOW RULES === + + - name: allow-workspace-read + effect: allow + principals: ["agent:*"] + actions: + - fs.read + - fs.read_file + - fs.list + - fs.readdir + - fs.stat + - fs.exists + - fs.glob + - file.read + - read + - glob + - grep + resources: ["*"] + + - name: allow-workspace-write + effect: allow + principals: ["agent:*"] + actions: + - fs.write + - fs.write_file + - fs.create + - fs.mkdir + - file.write + - write + - edit + resources: + - "*/workspace/*" + - "*/projects/*" + - "*/src/*" + - "*/code/*" + - "*/.secureclaw/*" + - "*/Downloads/*" + - "*/Documents/*" + - "./*" + - "src/*" + - "lib/*" + - "test/*" + - "tests/*" + - "docs/*" + required_labels: + - intent_verified + + - name: allow-workspace-delete + effect: allow + principals: ["agent:*"] + actions: + - fs.delete + - fs.remove + - fs.rmdir + - file.delete + - delete + resources: + - "*/workspace/*" + - "*/projects/*" + - "*/.secureclaw/*" + - "./*" + required_labels: + - intent_verified + - deletion_confirmed + + - name: allow-safe-shell-commands + effect: allow + principals: ["agent:*"] + actions: + - shell.exec + - bash + - exec + - run + resources: + # File viewing + - "cat *" + - "head *" + - "tail *" + - "less *" + - "bat *" + # Directory listing + - "ls*" + - "tree *" + - "pwd" + - "cd *" + # Search + - "find *" + - "grep *" + - "rg *" + - "ag *" + # Text processing + - "wc *" + - "sort *" + - "uniq *" + - "cut *" + - "awk *" + - "sed *" + - "jq *" + # System info + - "whoami" + - "hostname" + - "uname *" + - "date" + - "env" + - "which *" + # Development tools + - "node *" + - "npm run*" + - "npm test*" + - "npm install*" + - "npx *" + - "pnpm *" + - "yarn *" + - "bun *" + - "python *" + - "python3 *" + - "pip install*" + - "cargo *" + - "go *" + - "make *" + - "git *" + # Container info (read-only) + - "docker ps*" + - "docker images*" + - "docker logs*" + + - name: allow-build-commands + effect: allow + principals: ["agent:*"] + actions: + - shell.exec + - bash + - exec + resources: + - "npm run build*" + - "npm run test*" + - "pnpm build*" + - "pnpm test*" + - "yarn build*" + - "yarn test*" + - "make build*" + - "make test*" + - "cargo build*" + - "cargo test*" + - "go build*" + - "go test*" + required_labels: + - intent_verified + + - name: allow-https-requests + effect: allow + principals: ["agent:*"] + actions: + - http.request + - http.get + - http.post + - http.put + - http.patch + - http.delete + - fetch + resources: ["https://*"] + + - name: allow-browser-navigation + effect: allow + principals: ["agent:*"] + actions: + - browser.navigate + - browser.goto + resources: ["https://*"] + required_labels: + - browser_initialized + + - name: allow-browser-interactions + effect: allow + principals: ["agent:*"] + actions: + - browser.snapshot + - browser.screenshot + - browser.click + - browser.type + - browser.fill + - browser.scroll + - browser.wait + - browser.read + - browser.extract + resources: ["*"] + required_labels: + - browser_initialized + - snapshot_captured + + # === DEFAULT DENY === + + - name: default-deny + effect: deny + principals: ["*"] + actions: ["*"] + resources: ["*"] diff --git a/src/http/execute.rs b/src/http/execute.rs index d5e6999..45bb450 100644 --- a/src/http/execute.rs +++ b/src/http/execute.rs @@ -15,6 +15,15 @@ //! //! This ensures that an agent cannot request authorization for one resource //! but actually access a different resource. +//! +//! ## Secret Injection +//! +//! Policy rules can define `inject_headers` and `inject_env` to inject secrets +//! at execution time. The sidecar reads environment variables from its own process +//! and substitutes them into headers (for http.fetch) or environment variables +//! (for cli.exec). Supported syntax: +//! - `${VAR_NAME}` - Required variable, fails if not set +//! - `${VAR_NAME:-default}` - Optional variable with default value use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use sha2::{Digest, Sha256}; @@ -22,13 +31,14 @@ use std::collections::HashMap; use std::path::Path; use tokio::fs; use tokio::process::Command; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; use crate::http::AppState; use crate::models::{ AuthorizationReason, DirectoryEntry, ExecutePayload, ExecuteRequest, ExecuteResponse, - ExecuteResult, + ExecuteResult, PolicyRule, SidecarAuthorizeRequest, }; +use crate::secrets::substitute_env_vars_in_map; /// POST /v1/execute /// @@ -136,37 +146,42 @@ pub async fn execute_handler( ); } - // 5. Execute the operation - let (result, evidence_hash) = match execute_action(&request).await { - Ok((r, h)) => (r, h), - Err(e) => { - // Record failed execution in audit log - let audit_id = record_execution( - &state, - &request.mandate_id, - &request.action, - &request.resource, - false, - Some(&e), - ); - - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ExecuteResponse { - success: false, - result: None, - error: Some(e), - audit_id, - evidence_hash: None, - }), - ); - } - }; + // 5. Find matching policy rule for secret injection + // We look up the rule that would have authorized this action/resource + let matched_rule = find_matching_rule(&state, &request); + + // 6. Execute the operation with secret injection + let (result, evidence_hash) = + match execute_action_with_injection(&request, matched_rule.as_ref()).await { + Ok((r, h)) => (r, h), + Err(e) => { + // Record failed execution in audit log + let audit_id = record_execution( + &state, + &request.mandate_id, + &request.action, + &request.resource, + false, + Some(&e), + ); + + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ExecuteResponse { + success: false, + result: None, + error: Some(e), + audit_id, + evidence_hash: None, + }), + ); + } + }; - // 6. Mark mandate as executed + // 7. Mark mandate as executed mandate_store.mark_executed(&request.mandate_id); - // 7. Record successful execution in audit log + // 8. Record successful execution in audit log let audit_id = record_execution( &state, &request.mandate_id, @@ -193,6 +208,61 @@ pub async fn execute_handler( ) } +/// Find the policy rule that matches the request for secret injection. +/// +/// This looks up the first matching ALLOW rule to extract `inject_headers` or `inject_env`. +fn find_matching_rule(state: &AppState, request: &ExecuteRequest) -> Option { + // Build a synthetic authorization request to find the matching rule + let auth_request = SidecarAuthorizeRequest { + principal: format!("execute:{}", request.mandate_id), + action: request.action.clone(), + resource: request.resource.clone(), + scopes: vec![], + intent_hash: None, + context: serde_json::Value::Null, + labels: vec![], + }; + + // Find the first matching ALLOW rule + let rules = state.policy_engine.get_rules(); + for rule in rules { + if rule.effect == crate::models::PolicyEffect::Allow { + // Check if action and resource match this rule + let action_matches = rule + .actions + .iter() + .any(|a| glob_matches(a, &auth_request.action)); + let resource_matches = rule + .resources + .iter() + .any(|r| glob_matches(r, &auth_request.resource)); + + if action_matches && resource_matches { + // Check if rule has injection config + if rule.inject_headers.is_some() || rule.inject_env.is_some() { + debug!( + "Found policy rule '{}' with secret injection for {}/{}", + rule.name, request.action, request.resource + ); + return Some(rule); + } + } + } + } + None +} + +/// Simple glob pattern matching for rule lookup +fn glob_matches(pattern: &str, value: &str) -> bool { + if pattern == "*" { + return true; + } + if let Ok(p) = glob::Pattern::new(pattern) { + return p.matches(value); + } + pattern == value +} + /// Check if actions match (supports wildcard suffix) fn actions_match(authorized: &str, actual: &str) -> bool { if authorized == actual { @@ -236,8 +306,11 @@ fn normalize_path(path: &str) -> String { .to_string() } -/// Execute the requested action -async fn execute_action(request: &ExecuteRequest) -> Result<(ExecuteResult, String), String> { +/// Execute the requested action with optional secret injection from policy rules +async fn execute_action_with_injection( + request: &ExecuteRequest, + matched_rule: Option<&PolicyRule>, +) -> Result<(ExecuteResult, String), String> { match request.action.as_str() { "fs.read" => execute_fs_read(&request.resource).await, "fs.write" => { @@ -277,12 +350,15 @@ async fn execute_action(request: &ExecuteRequest) -> Result<(ExecuteResult, Stri timeout_ms, } = payload { + // Get inject_env from matched rule + let inject_env = matched_rule.and_then(|r| r.inject_env.as_ref()); execute_cli( &request.resource, command, args, cwd.as_deref(), *timeout_ms, + inject_env, ) .await } else { @@ -291,7 +367,9 @@ async fn execute_action(request: &ExecuteRequest) -> Result<(ExecuteResult, Stri } "http.fetch" => { let payload = request.payload.as_ref(); - execute_http_fetch(&request.resource, payload).await + // Get inject_headers from matched rule + let inject_headers = matched_rule.and_then(|r| r.inject_headers.as_ref()); + execute_http_fetch(&request.resource, payload, inject_headers).await } "env.read" => { let payload = request @@ -381,13 +459,17 @@ async fn execute_fs_write( )) } -/// Execute cli.exec +/// Execute cli.exec with optional environment variable injection +/// +/// If `inject_env` is provided from a matching policy rule, environment variables +/// are substituted from the sidecar's process environment and set for the command. async fn execute_cli( _resource: &str, command: &str, args: &[String], cwd: Option<&str>, timeout_ms: Option, + inject_env: Option<&HashMap>, ) -> Result<(ExecuteResult, String), String> { let start = std::time::Instant::now(); @@ -398,6 +480,17 @@ async fn execute_cli( cmd.current_dir(dir); } + // Inject environment variables from policy rule + if let Some(env_templates) = inject_env { + let resolved_env = substitute_env_vars_in_map(env_templates) + .map_err(|e| format!("Secret injection failed: {}", e))?; + + for (key, value) in resolved_env { + debug!("Injecting env var: {} (value redacted)", key); + cmd.env(key, value); + } + } + // Execute with timeout let output = if let Some(timeout) = timeout_ms { tokio::time::timeout(std::time::Duration::from_millis(timeout), cmd.output()) @@ -431,10 +524,15 @@ async fn execute_cli( )) } -/// Execute http.fetch +/// Execute http.fetch with optional header injection +/// +/// If `inject_headers` is provided from a matching policy rule, headers are +/// substituted from the sidecar's process environment and added to the request. +/// Injected headers take precedence over payload headers with the same name. async fn execute_http_fetch( resource: &str, payload: Option<&ExecutePayload>, + inject_headers: Option<&HashMap>, ) -> Result<(ExecuteResult, String), String> { let client = reqwest::Client::new(); @@ -454,7 +552,7 @@ async fn execute_http_fetch( _ => return Err(format!("Unsupported HTTP method: {}", method)), }; - // Add headers and body from payload + // Add headers and body from payload first if let Some(ExecutePayload::HttpFetch { headers, body, .. }) = payload { if let Some(hdrs) = headers { for (k, v) in hdrs { @@ -466,6 +564,17 @@ async fn execute_http_fetch( } } + // Inject headers from policy rule (these override payload headers) + if let Some(header_templates) = inject_headers { + let resolved_headers = substitute_env_vars_in_map(header_templates) + .map_err(|e| format!("Secret injection failed: {}", e))?; + + for (key, value) in resolved_headers { + debug!("Injecting header: {} (value redacted)", key); + request = request.header(&key, &value); + } + } + let response = request .send() .await diff --git a/src/http/mod.rs b/src/http/mod.rs index 2dfac66..32180bf 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -928,6 +928,8 @@ mod tests { resources: vec!["test://resource".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }]; let policy = PolicyEngine::with_rules(rules); // Disable SSRF protection for test URLs @@ -1026,6 +1028,8 @@ mod tests { resources: vec!["test://resource".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }]; let policy = PolicyEngine::with_rules(rules); policy.set_ssrf_protection(None); @@ -1102,6 +1106,8 @@ mod tests { resources: vec!["https://amazon.com/*".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }, PolicyRule { name: "allow-fs".to_string(), @@ -1111,6 +1117,8 @@ mod tests { resources: vec!["/workspace/**".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }, ]; let policy = PolicyEngine::with_rules(rules); diff --git a/src/lib.rs b/src/lib.rs index 1a2b282..b715f92 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,5 +14,6 @@ pub mod policy; pub mod policy_loader; pub mod policy_signer; pub mod proof; +pub mod secrets; pub mod ssrf; pub mod ui; diff --git a/src/models/mod.rs b/src/models/mod.rs index 0972bad..e6fa0df 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -225,6 +225,14 @@ pub struct PolicyRule { pub required_labels: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub max_delegation_depth: Option, + /// Headers to inject for http.fetch actions. + /// Values support environment variable substitution: `${VAR_NAME}` or `${VAR:-default}` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inject_headers: Option>, + /// Environment variables to inject for cli.exec actions. + /// Values support environment variable substitution: `${VAR_NAME}` or `${VAR:-default}` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inject_env: Option>, } /// Mandate claims (JWT payload) diff --git a/src/policy/mod.rs b/src/policy/mod.rs index f57fa9b..5bc64a8 100644 --- a/src/policy/mod.rs +++ b/src/policy/mod.rs @@ -91,6 +91,13 @@ impl PolicyEngine { self.rules.read().len() } + /// Get a copy of all rules (thread-safe) + /// + /// Used for looking up rules with inject_headers/inject_env for secret injection. + pub fn get_rules(&self) -> Vec { + self.rules.read().clone() + } + /// Evaluate a request against the policy rules pub fn evaluate(&self, request: &SidecarAuthorizeRequest) -> PolicyMatchResult { self.evaluate_with_labels(request, &request.labels) @@ -296,6 +303,8 @@ mod tests { resources: vec!["https://*".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, } } @@ -308,6 +317,8 @@ mod tests { resources: vec!["*".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, } } @@ -357,6 +368,8 @@ mod tests { resources: vec!["*checkout*".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }, sample_allow_rule(), ]); @@ -380,6 +393,8 @@ mod tests { resources: vec!["*".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }, ]); @@ -399,6 +414,8 @@ mod tests { resources: vec!["https://*".to_string()], required_labels: vec!["mfa_verified".to_string()], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }; let engine = PolicyEngine::with_rules(vec![rule]); @@ -422,6 +439,8 @@ mod tests { resources: vec!["https://*".to_string()], required_labels: vec!["mfa_verified".to_string()], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }; let engine = PolicyEngine::with_rules(vec![rule]); @@ -466,6 +485,8 @@ mod tests { resources: vec!["*".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }]); let request = SidecarAuthorizeRequest { @@ -494,6 +515,8 @@ mod tests { resources: vec!["*".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }]); let request = SidecarAuthorizeRequest { @@ -521,6 +544,8 @@ mod tests { resources: vec!["*".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }]); let request = SidecarAuthorizeRequest { @@ -547,6 +572,8 @@ mod tests { resources: vec!["*".to_string()], required_labels: vec![], max_delegation_depth: None, + inject_headers: None, + inject_env: None, }]); // Disable SSRF protection diff --git a/src/secrets.rs b/src/secrets.rs new file mode 100644 index 0000000..79b4878 --- /dev/null +++ b/src/secrets.rs @@ -0,0 +1,291 @@ +//! Secret injection utilities for environment variable substitution. +//! +//! This module provides functions to substitute environment variables in strings, +//! enabling policy-driven secret injection for HTTP headers and CLI environment variables. +//! +//! ## Supported Syntax +//! +//! - `${VAR_NAME}` - Substitutes with the value of VAR_NAME, errors if missing +//! - `${VAR_NAME:-default}` - Substitutes with VAR_NAME value, or "default" if missing +//! +//! ## Security +//! +//! - Only reads from the sidecar process environment +//! - Never exposes secret values to agents (values are injected at execution time) +//! - Supports zeroization of sensitive values when possible + +use regex::Regex; +use std::collections::HashMap; +use std::sync::LazyLock; +use thiserror::Error; + +/// Regex pattern for environment variable substitution. +/// Matches: ${VAR_NAME} or ${VAR_NAME:-default_value} +static ENV_VAR_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}").expect("Invalid regex pattern") +}); + +/// Error type for secret substitution +#[derive(Debug, Error)] +pub enum SecretSubstitutionError { + #[error("Missing environment variable: {0}")] + MissingEnvVar(String), + + #[error("Environment variable substitution failed: {0}")] + SubstitutionFailed(String), +} + +/// Result type for secret substitution operations +pub type SubstitutionResult = Result; + +/// Substitute environment variables in a template string. +/// +/// Supports two syntaxes: +/// - `${VAR_NAME}` - Required variable, returns error if not set +/// - `${VAR_NAME:-default}` - Optional variable with default value +/// +/// # Examples +/// +/// ```ignore +/// // Required variable +/// let result = substitute_env_vars("Bearer ${API_TOKEN}")?; +/// +/// // With default value +/// let result = substitute_env_vars("${API_URL:-https://api.example.com}")?; +/// ``` +pub fn substitute_env_vars(template: &str) -> SubstitutionResult { + let mut result = template.to_string(); + let mut errors = Vec::new(); + + // Find all matches and substitute them + for cap in ENV_VAR_PATTERN.captures_iter(template) { + let full_match = cap.get(0).unwrap().as_str(); + let var_name = cap.get(1).unwrap().as_str(); + let default_value = cap.get(2).map(|m| m.as_str()); + + match std::env::var(var_name) { + Ok(value) => { + result = result.replace(full_match, &value); + } + Err(_) => { + if let Some(default) = default_value { + result = result.replace(full_match, default); + } else { + errors.push(var_name.to_string()); + } + } + } + } + + if !errors.is_empty() { + return Err(SecretSubstitutionError::MissingEnvVar(errors.join(", "))); + } + + Ok(result) +} + +/// Substitute environment variables in a HashMap of strings. +/// +/// Used for processing `inject_headers` and `inject_env` from policy rules. +/// All values in the map are processed for substitution. +/// +/// # Arguments +/// +/// * `templates` - HashMap where values may contain `${VAR}` patterns +/// +/// # Returns +/// +/// A new HashMap with all environment variables substituted. +pub fn substitute_env_vars_in_map( + templates: &HashMap, +) -> SubstitutionResult> { + let mut result = HashMap::with_capacity(templates.len()); + + for (key, template) in templates { + let value = substitute_env_vars(template)?; + result.insert(key.clone(), value); + } + + Ok(result) +} + +/// Check if a template string contains any environment variable references. +/// +/// Useful for validation at policy load time. +pub fn has_env_var_references(template: &str) -> bool { + ENV_VAR_PATTERN.is_match(template) +} + +/// Extract all environment variable names referenced in a template. +/// +/// Useful for validation and debugging. +pub fn extract_env_var_names(template: &str) -> Vec { + ENV_VAR_PATTERN + .captures_iter(template) + .map(|cap| cap.get(1).unwrap().as_str().to_string()) + .collect() +} + +/// Validate that all environment variables in a template are set. +/// +/// Returns a list of missing variable names, or empty vec if all are present. +pub fn validate_env_vars(template: &str) -> Vec { + let mut missing = Vec::new(); + + for cap in ENV_VAR_PATTERN.captures_iter(template) { + let var_name = cap.get(1).unwrap().as_str(); + let has_default = cap.get(2).is_some(); + + if !has_default && std::env::var(var_name).is_err() { + missing.push(var_name.to_string()); + } + } + + missing +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_substitute_simple_var() { + std::env::set_var("TEST_SECRET_1", "my-secret-value"); + + let result = substitute_env_vars("Bearer ${TEST_SECRET_1}").unwrap(); + assert_eq!(result, "Bearer my-secret-value"); + + std::env::remove_var("TEST_SECRET_1"); + } + + #[test] + fn test_substitute_multiple_vars() { + std::env::set_var("TEST_USER", "admin"); + std::env::set_var("TEST_PASS", "secret123"); + + let result = substitute_env_vars("${TEST_USER}:${TEST_PASS}").unwrap(); + assert_eq!(result, "admin:secret123"); + + std::env::remove_var("TEST_USER"); + std::env::remove_var("TEST_PASS"); + } + + #[test] + fn test_substitute_with_default() { + // Variable not set, should use default + std::env::remove_var("NONEXISTENT_VAR"); + + let result = substitute_env_vars("${NONEXISTENT_VAR:-default_value}").unwrap(); + assert_eq!(result, "default_value"); + } + + #[test] + fn test_substitute_with_default_empty() { + std::env::remove_var("EMPTY_DEFAULT_VAR"); + + let result = substitute_env_vars("${EMPTY_DEFAULT_VAR:-}").unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn test_substitute_prefers_env_over_default() { + std::env::set_var("TEST_PRIORITY", "from-env"); + + let result = substitute_env_vars("${TEST_PRIORITY:-default}").unwrap(); + assert_eq!(result, "from-env"); + + std::env::remove_var("TEST_PRIORITY"); + } + + #[test] + fn test_substitute_missing_required_var() { + std::env::remove_var("DEFINITELY_NOT_SET"); + + let result = substitute_env_vars("Bearer ${DEFINITELY_NOT_SET}"); + assert!(result.is_err()); + + let err = result.unwrap_err(); + assert!(err.to_string().contains("DEFINITELY_NOT_SET")); + } + + #[test] + fn test_substitute_in_map() { + std::env::set_var("TEST_API_KEY", "key123"); + std::env::set_var("TEST_ORG_ID", "org456"); + + let mut templates = HashMap::new(); + templates.insert( + "Authorization".to_string(), + "Bearer ${TEST_API_KEY}".to_string(), + ); + templates.insert("X-Org-ID".to_string(), "${TEST_ORG_ID}".to_string()); + + let result = substitute_env_vars_in_map(&templates).unwrap(); + + assert_eq!(result.get("Authorization").unwrap(), "Bearer key123"); + assert_eq!(result.get("X-Org-ID").unwrap(), "org456"); + + std::env::remove_var("TEST_API_KEY"); + std::env::remove_var("TEST_ORG_ID"); + } + + #[test] + fn test_has_env_var_references() { + assert!(has_env_var_references("Bearer ${TOKEN}")); + assert!(has_env_var_references("${VAR:-default}")); + assert!(!has_env_var_references("plain text")); + assert!(!has_env_var_references("not a ${incomplete")); + assert!(!has_env_var_references("$NOT_BRACED")); + } + + #[test] + fn test_extract_env_var_names() { + let names = extract_env_var_names("${VAR1} and ${VAR2:-default}"); + assert_eq!(names, vec!["VAR1", "VAR2"]); + } + + #[test] + fn test_validate_env_vars() { + std::env::set_var("TEST_EXISTS", "value"); + std::env::remove_var("TEST_MISSING"); + + let missing = validate_env_vars("${TEST_EXISTS} ${TEST_MISSING} ${TEST_DEFAULT:-d}"); + assert_eq!(missing, vec!["TEST_MISSING"]); + + std::env::remove_var("TEST_EXISTS"); + } + + #[test] + fn test_no_substitution_needed() { + let result = substitute_env_vars("plain text without variables").unwrap(); + assert_eq!(result, "plain text without variables"); + } + + #[test] + fn test_complex_template() { + std::env::set_var("TEST_HOST", "api.example.com"); + std::env::set_var("TEST_TOKEN", "secret123"); + + let template = "https://${TEST_HOST}/api?key=${TEST_TOKEN}&format=${FMT:-json}"; + let result = substitute_env_vars(template).unwrap(); + assert_eq!( + result, + "https://api.example.com/api?key=secret123&format=json" + ); + + std::env::remove_var("TEST_HOST"); + std::env::remove_var("TEST_TOKEN"); + } + + #[test] + fn test_underscore_and_numbers_in_var_names() { + std::env::set_var("MY_VAR_123", "value"); + std::env::set_var("_PRIVATE_VAR", "private"); + + let result = substitute_env_vars("${MY_VAR_123} ${_PRIVATE_VAR}").unwrap(); + assert_eq!(result, "value private"); + + std::env::remove_var("MY_VAR_123"); + std::env::remove_var("_PRIVATE_VAR"); + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 16362ae..c3f15b1 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -72,6 +72,8 @@ async fn test_authorize_allow_rule() { actions: vec!["browser.*".to_string()], resources: vec!["*".to_string()], max_delegation_depth: None, + inject_headers: None, + inject_env: None, required_labels: vec![], }]; @@ -115,6 +117,8 @@ async fn test_authorize_deny_rule() { actions: vec!["admin.*".to_string()], resources: vec!["*".to_string()], max_delegation_depth: None, + inject_headers: None, + inject_env: None, required_labels: vec![], }]; @@ -276,6 +280,8 @@ async fn test_legacy_authorize_endpoint() { actions: vec!["*".to_string()], resources: vec!["*".to_string()], max_delegation_depth: None, + inject_headers: None, + inject_env: None, required_labels: vec![], }]; @@ -311,6 +317,8 @@ async fn test_authorize_with_labels() { actions: vec!["sensitive.*".to_string()], resources: vec!["*".to_string()], max_delegation_depth: None, + inject_headers: None, + inject_env: None, required_labels: vec!["approved".to_string(), "verified".to_string()], }]; @@ -364,6 +372,8 @@ async fn test_local_mode_no_token_required() { actions: vec!["*".to_string()], resources: vec!["*".to_string()], max_delegation_depth: None, + inject_headers: None, + inject_env: None, required_labels: vec![], }]; @@ -401,6 +411,8 @@ async fn test_local_idp_mode_requires_token() { actions: vec!["*".to_string()], resources: vec!["*".to_string()], max_delegation_depth: None, + inject_headers: None, + inject_env: None, required_labels: vec![], }]; @@ -446,6 +458,8 @@ async fn test_local_idp_mode_invalid_token() { actions: vec!["*".to_string()], resources: vec!["*".to_string()], max_delegation_depth: None, + inject_headers: None, + inject_env: None, required_labels: vec![], }]; @@ -550,6 +564,8 @@ async fn test_execute_mandate_not_found() { actions: vec!["fs.*".to_string()], resources: vec!["*".to_string()], max_delegation_depth: None, + inject_headers: None, + inject_env: None, required_labels: vec![], }]; @@ -600,6 +616,8 @@ async fn test_execute_with_stored_mandate() { actions: vec!["fs.*".to_string()], resources: vec!["*".to_string()], max_delegation_depth: None, + inject_headers: None, + inject_env: None, required_labels: vec![], }]; From 8c65b45e23818262b358894576378cfedda94196 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Thu, 12 Mar 2026 18:50:45 -0700 Subject: [PATCH 2/3] updated tests --- README.md | 80 ++++++++- src/policy/mod.rs | 77 +++++++++ tests/integration_test.rs | 351 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 507 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b8bb86f..904ffb7 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ Policies are JSON or YAML files with ALLOW/DENY rules: 2. ALLOW rules checked (must match + have required_labels) 3. Default DENY (fail-closed) -**Bundled templates:** `strict.json`, `read-only.json`, `ci-cd.json`, `permissive.json` +**Bundled templates:** `strict.json`, `read-only.json`, `ci-cd.json`, `permissive.json`, `secret-injection.json` --- @@ -387,6 +387,84 @@ curl -X POST http://127.0.0.1:8787/v1/execute \ --- +## Secret Injection + +Policy rules can inject secrets at execution time. Agents never see raw credentials—the sidecar substitutes environment variables when executing actions. + +``` +┌─────────┐ authorize ┌──────────────┐ execute ┌─────────┐ +│ Agent │ ─────────────────▶│ Sidecar │ ────────────────▶│ Backend │ +│ │ (no secrets) │ inject: $KEY │ (with secrets) │ API │ +└─────────┘ └──────────────┘ └─────────┘ +``` + +**Policy with header injection:** + +```json +{ + "rules": [ + { + "name": "github-api-with-auth", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.fetch"], + "resources": ["https://api.github.com/*"], + "inject_headers": { + "Authorization": "Bearer ${GITHUB_TOKEN}", + "Accept": "application/vnd.github.v3+json" + } + } + ] +} +``` + +**Policy with CLI environment injection:** + +```json +{ + "rules": [ + { + "name": "aws-cli-with-credentials", + "effect": "allow", + "principals": ["agent:ops"], + "actions": ["cli.exec"], + "resources": ["aws", "aws *"], + "inject_env": { + "AWS_ACCESS_KEY_ID": "${AWS_ACCESS_KEY_ID}", + "AWS_SECRET_ACCESS_KEY": "${AWS_SECRET_ACCESS_KEY}", + "AWS_DEFAULT_REGION": "${AWS_REGION:-us-east-1}" + } + } + ] +} +``` + +**Syntax:** +- `${VAR_NAME}` — Substitute from environment (required) +- `${VAR_NAME:-default}` — Use default if not set + +**Usage:** + +```bash +# Set secrets as environment variables +export GITHUB_TOKEN="ghp_xxxxxxxxxxxx" +export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE" +export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + +# Start sidecar - secrets stay here +./predicate-authorityd --policy-file policy.json run +``` + +**Security benefits:** +- Agents never see or handle raw secrets +- Policy controls which secrets are injected where +- Even compromised agents cannot exfiltrate credentials +- Works with existing agents without code changes + +See [policies/secret-injection.json](policies/secret-injection.json) for a complete example. + +--- + ## Roadmap: Planned Actions The following actions are planned to support autonomous agent workflows: diff --git a/src/policy/mod.rs b/src/policy/mod.rs index 5bc64a8..c120b5a 100644 --- a/src/policy/mod.rs +++ b/src/policy/mod.rs @@ -81,11 +81,88 @@ impl PolicyEngine { } /// Replace all rules (thread-safe) + /// + /// This also validates secret injection references and logs warnings + /// for any missing environment variables. pub fn replace_rules(&self, rules: Vec) { + // Validate secret references before loading + self.validate_secret_references(&rules); + let mut guard = self.rules.write(); *guard = rules; } + /// Validate that all environment variables referenced in inject_headers/inject_env + /// are available in the current environment. + /// + /// This logs warnings for missing variables but does not prevent policy loading, + /// since variables might be set later or use defaults. + fn validate_secret_references(&self, rules: &[PolicyRule]) { + use crate::secrets::validate_env_vars; + + for rule in rules { + // Check inject_headers + if let Some(ref headers) = rule.inject_headers { + for (header_name, template) in headers { + let missing = validate_env_vars(template); + for var_name in missing { + tracing::warn!( + rule = %rule.name, + header = %header_name, + variable = %var_name, + "Policy references undefined environment variable in inject_headers" + ); + } + } + } + + // Check inject_env + if let Some(ref env_vars) = rule.inject_env { + for (env_name, template) in env_vars { + let missing = validate_env_vars(template); + for var_name in missing { + tracing::warn!( + rule = %rule.name, + env_key = %env_name, + variable = %var_name, + "Policy references undefined environment variable in inject_env" + ); + } + } + } + } + } + + /// Get all missing environment variables referenced by secret injection rules. + /// + /// Returns a vector of (rule_name, var_name) tuples for any missing variables. + pub fn get_missing_secret_references(&self) -> Vec<(String, String)> { + use crate::secrets::validate_env_vars; + + let mut missing = Vec::new(); + let rules = self.rules.read(); + + for rule in rules.iter() { + if let Some(ref headers) = rule.inject_headers { + for template in headers.values() { + for var_name in validate_env_vars(template) { + missing.push((rule.name.clone(), var_name)); + } + } + } + + if let Some(ref env_vars) = rule.inject_env { + for template in env_vars.values() { + for var_name in validate_env_vars(template) { + missing.push((rule.name.clone(), var_name)); + } + } + } + } + + missing + } + /// Get current rule count pub fn rule_count(&self) -> usize { self.rules.read().len() diff --git a/tests/integration_test.rs b/tests/integration_test.rs index c3f15b1..cfa62d3 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -955,3 +955,354 @@ async fn test_execute_expired_mandate() { assert_eq!(resp["success"], false); assert!(resp["error"].as_str().unwrap().contains("Mandate expired")); } + +// --- Secret Injection Tests (Phase 2) --- + +#[tokio::test] +async fn test_secret_injection_cli_exec() { + use predicate_authorityd::mandate::MandateStore; + use predicate_authorityd::models::{MandateClaims, SignedMandate}; + use std::collections::HashMap; + use std::time::{SystemTime, UNIX_EPOCH}; + + // Set a test environment variable that will be injected + std::env::set_var("TEST_SECRET_VALUE_CLI", "secret123"); + + // Create policy rule with inject_env + let mut inject_env = HashMap::new(); + inject_env.insert( + "INJECTED_VAR".to_string(), + "${TEST_SECRET_VALUE_CLI}".to_string(), + ); + inject_env.insert( + "INJECTED_WITH_DEFAULT".to_string(), + "${NONEXISTENT_CLI_VAR:-fallback_value}".to_string(), + ); + + let rules = vec![PolicyRule { + name: "cli-with-injection".to_string(), + effect: predicate_authorityd::models::PolicyEffect::Allow, + principals: vec!["*".to_string()], + actions: vec!["cli.exec".to_string()], + resources: vec!["*".to_string()], + max_delegation_depth: None, + inject_headers: None, + inject_env: Some(inject_env), + required_labels: vec![], + }]; + + let engine = PolicyEngine::new(); + engine.replace_rules(rules); + let mandate_store = MandateStore::new(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let mandate = SignedMandate { + token: "test-token".to_string(), + claims: MandateClaims { + mandate_id: "m_cli_inject".to_string(), + principal_id: "agent:test".to_string(), + action: "cli.exec".to_string(), + resource: "sh".to_string(), + scopes: Vec::new(), + intent_hash: "hash123".to_string(), + state_hash: "state123".to_string(), + issued_at_epoch_s: now, + expires_at_epoch_s: now + 300, + delegated_by: None, + parent_mandate_id: None, + delegation_depth: 0, + delegation_chain_hash: Some("chain123".to_string()), + iss: Some("test".to_string()), + aud: Some("test".to_string()), + sub: Some("agent:test".to_string()), + iat: None, + exp: Some(now + 300), + nbf: None, + jti: Some("m_cli_inject".to_string()), + }, + signature: "test-signature".to_string(), + }; + + mandate_store.store(mandate); + + let state = AppState::new(engine, "local_only").with_mandate_store(mandate_store); + let app = create_router(state); + + // Use sh -c to echo the injected environment variables + let body = json!({ + "mandate_id": "m_cli_inject", + "action": "cli.exec", + "resource": "sh", + "payload": { + "type": "cli_exec", + "command": "sh", + "args": ["-c", "echo INJECTED_VAR=$INJECTED_VAR INJECTED_WITH_DEFAULT=$INJECTED_WITH_DEFAULT"] + } + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/execute") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let resp: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + + assert_eq!(resp["success"], true); + + let stdout = resp["result"]["stdout"].as_str().unwrap(); + + // Verify the injected values are present in output + assert!( + stdout.contains("secret123"), + "Expected secret123 in stdout: {}", + stdout + ); + assert!( + stdout.contains("fallback_value"), + "Expected fallback_value in stdout: {}", + stdout + ); + + // Cleanup + std::env::remove_var("TEST_SECRET_VALUE_CLI"); +} + +#[tokio::test] +async fn test_secret_injection_default_value_syntax() { + use predicate_authorityd::secrets::substitute_env_vars; + + // Test with existing variable + std::env::set_var("EXISTING_VAR_TEST", "existing_value"); + + // Existing variable without default - should use existing value + let result = substitute_env_vars("prefix_${EXISTING_VAR_TEST}_suffix").unwrap(); + assert_eq!(result, "prefix_existing_value_suffix"); + + // Existing variable with default - should use existing value, not default + let result = substitute_env_vars("${EXISTING_VAR_TEST:-default}").unwrap(); + assert_eq!(result, "existing_value"); + + // Non-existing variable with default - should use default + let result = substitute_env_vars("${NONEXISTENT_VAR_12345:-my_default}").unwrap(); + assert_eq!(result, "my_default"); + + // Non-existing variable without default - should ERROR (fail-closed behavior) + let result = substitute_env_vars("${NONEXISTENT_VAR_12345}"); + assert!(result.is_err(), "Missing required var should return error"); + + // Cleanup + std::env::remove_var("EXISTING_VAR_TEST"); +} + +#[tokio::test] +async fn test_secret_not_exposed_in_authorize_response() { + // Security test: Verify that inject_headers/inject_env values + // are NOT exposed in the authorize response + + let mut inject_headers = std::collections::HashMap::new(); + inject_headers.insert( + "Authorization".to_string(), + "Bearer ${API_SECRET}".to_string(), + ); + + std::env::set_var("API_SECRET", "super_secret_token_12345"); + + let rules = vec![PolicyRule { + name: "api-with-secrets".to_string(), + effect: predicate_authorityd::models::PolicyEffect::Allow, + principals: vec!["agent:*".to_string()], + actions: vec!["http.fetch".to_string()], + resources: vec!["https://api.example.com/*".to_string()], + max_delegation_depth: None, + inject_headers: Some(inject_headers), + inject_env: None, + required_labels: vec![], + }]; + + let app = create_router(test_state_with_rules(rules)); + + let body = json!({ + "principal": "agent:web", + "action": "http.fetch", + "resource": "https://api.example.com/data" + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/authorize") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body_str = String::from_utf8(body_bytes.to_vec()).unwrap(); + let resp: serde_json::Value = serde_json::from_str(&body_str).unwrap(); + + // Verify authorization succeeded + assert_eq!(resp["allowed"], true); + + // SECURITY: Verify the secret value is NOT in the response + assert!( + !body_str.contains("super_secret_token_12345"), + "Secret value leaked in authorize response!" + ); + assert!( + !body_str.contains("API_SECRET"), + "Secret variable name leaked in authorize response!" + ); + + // Cleanup + std::env::remove_var("API_SECRET"); +} + +#[tokio::test] +async fn test_policy_with_inject_headers_parses_correctly() { + // Test that policies with inject_headers can be loaded via /policy/reload + + let body = json!({ + "rules": [ + { + "name": "github-api", + "effect": "allow", + "principals": ["agent:*"], + "actions": ["http.fetch"], + "resources": ["https://api.github.com/*"], + "inject_headers": { + "Authorization": "Bearer ${GITHUB_TOKEN}", + "Accept": "application/vnd.github.v3+json" + } + }, + { + "name": "aws-cli", + "effect": "allow", + "principals": ["agent:ops"], + "actions": ["cli.exec"], + "resources": ["aws *"], + "inject_env": { + "AWS_ACCESS_KEY_ID": "${AWS_ACCESS_KEY_ID}", + "AWS_SECRET_ACCESS_KEY": "${AWS_SECRET_ACCESS_KEY}", + "AWS_DEFAULT_REGION": "${AWS_REGION:-us-east-1}" + } + } + ] + }); + + let app = create_router(test_state()); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/policy/reload") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let resp: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + + assert_eq!(resp["success"], true); + assert_eq!(resp["rule_count"], 2); +} + +#[tokio::test] +async fn test_secret_validation_detects_missing_env_vars() { + use std::collections::HashMap; + + // Set one variable but not others + std::env::set_var("EXISTING_SECRET_VAR", "value"); + std::env::remove_var("MISSING_SECRET_VAR_1"); + std::env::remove_var("MISSING_SECRET_VAR_2"); + + let mut inject_headers = HashMap::new(); + inject_headers.insert( + "Authorization".to_string(), + "Bearer ${MISSING_SECRET_VAR_1}".to_string(), + ); + inject_headers.insert( + "X-Existing".to_string(), + "${EXISTING_SECRET_VAR}".to_string(), + ); + + let mut inject_env = HashMap::new(); + inject_env.insert( + "MISSING_VAR".to_string(), + "${MISSING_SECRET_VAR_2}".to_string(), + ); + inject_env.insert( + "WITH_DEFAULT".to_string(), + "${ALSO_MISSING:-default}".to_string(), + ); // Has default, so not missing + + let rules = vec![PolicyRule { + name: "test-validation".to_string(), + effect: predicate_authorityd::models::PolicyEffect::Allow, + principals: vec!["*".to_string()], + actions: vec!["*".to_string()], + resources: vec!["*".to_string()], + max_delegation_depth: None, + inject_headers: Some(inject_headers), + inject_env: Some(inject_env), + required_labels: vec![], + }]; + + let engine = PolicyEngine::new(); + engine.replace_rules(rules); + + // Check which env vars are missing + let missing = engine.get_missing_secret_references(); + + // Should have 2 missing: MISSING_SECRET_VAR_1, MISSING_SECRET_VAR_2 + // (ALSO_MISSING has a default so it's not considered missing) + assert_eq!( + missing.len(), + 2, + "Expected 2 missing vars, got: {:?}", + missing + ); + + let missing_vars: Vec<&str> = missing.iter().map(|(_, v)| v.as_str()).collect(); + assert!( + missing_vars.contains(&"MISSING_SECRET_VAR_1"), + "Should detect MISSING_SECRET_VAR_1" + ); + assert!( + missing_vars.contains(&"MISSING_SECRET_VAR_2"), + "Should detect MISSING_SECRET_VAR_2" + ); + + // Cleanup + std::env::remove_var("EXISTING_SECRET_VAR"); +} From 99e6e13130c86afd930f2d0f27e624d97db9ca33 Mon Sep 17 00:00:00 2001 From: SentienceDEV Date: Thu, 12 Mar 2026 19:11:26 -0700 Subject: [PATCH 3/3] syntax for defaults from large secrets --- docs/sidecar-user-manual.md | 42 +++++++++ src/http/execute.rs | 125 +++++++++++++++++++++------ src/http/mod.rs | 30 +++++++ src/models/mod.rs | 9 ++ src/policy/mod.rs | 20 +++++ src/proof/mod.rs | 29 +++++++ src/secrets.rs | 165 ++++++++++++++++++++++++++++++++++++ tests/integration_test.rs | 155 +++++++++++++++++++++++++++++++++ 8 files changed, 551 insertions(+), 24 deletions(-) diff --git a/docs/sidecar-user-manual.md b/docs/sidecar-user-manual.md index 032c65b..13f8685 100644 --- a/docs/sidecar-user-manual.md +++ b/docs/sidecar-user-manual.md @@ -1193,6 +1193,39 @@ rules: KUBECONFIG: "${KUBECONFIG:-/etc/kubernetes/admin.conf}" ``` +### File-Based Secret Injection + +For large secrets like certificates, private keys, or multi-line content, use `inject_headers_from_file` and `inject_env_from_file` to read values from files instead of environment variables: + +```yaml +rules: + # Inject certificate from file + - name: mtls-api + effect: allow + principals: ["agent:*"] + actions: ["http.fetch"] + resources: ["https://secure-api.example.com/*"] + inject_headers: + Authorization: "Bearer ${API_TOKEN}" + inject_headers_from_file: + X-Client-Cert: "/etc/certs/client.pem" + + # Inject SSH key from file for CLI commands + - name: git-operations + effect: allow + principals: ["agent:deployer"] + actions: ["cli.exec"] + resources: ["git", "ssh"] + inject_env_from_file: + SSH_PRIVATE_KEY: "${HOME}/.ssh/deploy_key" +``` + +**Key behaviors:** +- File paths support environment variable substitution (e.g., `${HOME}/.ssh/key`) +- File contents are trimmed of trailing whitespace/newlines +- File-based secrets take precedence over env-var-based secrets for the same key +- Missing files cause execution to fail (fail-closed) + ### Complete Example **Policy file (`policy-with-secrets.yaml`):** @@ -1230,6 +1263,15 @@ rules: AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID}" AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY}" AWS_DEFAULT_REGION: "${AWS_REGION:-us-east-1}" + + # mTLS with certificate from file + - name: secure-api + effect: allow + principals: ["agent:secure"] + actions: ["http.fetch"] + resources: ["https://internal-api.example.com/*"] + inject_headers_from_file: + X-Client-Certificate: "/etc/certs/client.pem" ``` **Starting the sidecar:** diff --git a/src/http/execute.rs b/src/http/execute.rs index 45bb450..db412c4 100644 --- a/src/http/execute.rs +++ b/src/http/execute.rs @@ -38,7 +38,8 @@ use crate::models::{ AuthorizationReason, DirectoryEntry, ExecutePayload, ExecuteRequest, ExecuteResponse, ExecuteResult, PolicyRule, SidecarAuthorizeRequest, }; -use crate::secrets::substitute_env_vars_in_map; +use crate::proof::InMemoryProofLedger; +use crate::secrets::{merge_maps, read_secrets_from_files, substitute_env_vars_in_map}; /// POST /v1/execute /// @@ -152,7 +153,9 @@ pub async fn execute_handler( // 6. Execute the operation with secret injection let (result, evidence_hash) = - match execute_action_with_injection(&request, matched_rule.as_ref()).await { + match execute_action_with_injection(&request, matched_rule.as_ref(), &state.proof_ledger) + .await + { Ok((r, h)) => (r, h), Err(e) => { // Record failed execution in audit log @@ -238,8 +241,12 @@ fn find_matching_rule(state: &AppState, request: &ExecuteRequest) -> Option String { async fn execute_action_with_injection( request: &ExecuteRequest, matched_rule: Option<&PolicyRule>, + proof_ledger: &InMemoryProofLedger, ) -> Result<(ExecuteResult, String), String> { match request.action.as_str() { "fs.read" => execute_fs_read(&request.resource).await, @@ -350,8 +358,18 @@ async fn execute_action_with_injection( timeout_ms, } = payload { - // Get inject_env from matched rule + // Get inject_env and inject_env_from_file from matched rule let inject_env = matched_rule.and_then(|r| r.inject_env.as_ref()); + let inject_env_from_file = + matched_rule.and_then(|r| r.inject_env_from_file.as_ref()); + + // Record injection metrics + let env_count = inject_env.map_or(0, |m| m.len()); + let env_file_count = inject_env_from_file.map_or(0, |m| m.len()); + if env_count > 0 || env_file_count > 0 { + proof_ledger.record_injection(0, 0, env_count, env_file_count); + } + execute_cli( &request.resource, command, @@ -359,6 +377,7 @@ async fn execute_action_with_injection( cwd.as_deref(), *timeout_ms, inject_env, + inject_env_from_file, ) .await } else { @@ -367,9 +386,25 @@ async fn execute_action_with_injection( } "http.fetch" => { let payload = request.payload.as_ref(); - // Get inject_headers from matched rule + // Get inject_headers and inject_headers_from_file from matched rule let inject_headers = matched_rule.and_then(|r| r.inject_headers.as_ref()); - execute_http_fetch(&request.resource, payload, inject_headers).await + let inject_headers_from_file = + matched_rule.and_then(|r| r.inject_headers_from_file.as_ref()); + + // Record injection metrics + let header_count = inject_headers.map_or(0, |m| m.len()); + let header_file_count = inject_headers_from_file.map_or(0, |m| m.len()); + if header_count > 0 || header_file_count > 0 { + proof_ledger.record_injection(header_count, header_file_count, 0, 0); + } + + execute_http_fetch( + &request.resource, + payload, + inject_headers, + inject_headers_from_file, + ) + .await } "env.read" => { let payload = request @@ -463,6 +498,8 @@ async fn execute_fs_write( /// /// If `inject_env` is provided from a matching policy rule, environment variables /// are substituted from the sidecar's process environment and set for the command. +/// If `inject_env_from_file` is provided, secrets are read from files and merged. +/// File-based secrets take precedence over env-var-based secrets for same keys. async fn execute_cli( _resource: &str, command: &str, @@ -470,6 +507,7 @@ async fn execute_cli( cwd: Option<&str>, timeout_ms: Option, inject_env: Option<&HashMap>, + inject_env_from_file: Option<&HashMap>, ) -> Result<(ExecuteResult, String), String> { let start = std::time::Instant::now(); @@ -480,15 +518,33 @@ async fn execute_cli( cmd.current_dir(dir); } - // Inject environment variables from policy rule - if let Some(env_templates) = inject_env { - let resolved_env = substitute_env_vars_in_map(env_templates) - .map_err(|e| format!("Secret injection failed: {}", e))?; + // Resolve env-var-based secrets + let resolved_env = if let Some(env_templates) = inject_env { + Some( + substitute_env_vars_in_map(env_templates) + .map_err(|e| format!("Secret injection failed: {}", e))?, + ) + } else { + None + }; - for (key, value) in resolved_env { - debug!("Injecting env var: {} (value redacted)", key); - cmd.env(key, value); - } + // Resolve file-based secrets + let file_env = if let Some(file_templates) = inject_env_from_file { + Some( + read_secrets_from_files(file_templates) + .map_err(|e| format!("File secret injection failed: {}", e))?, + ) + } else { + None + }; + + // Merge env vars (file-based takes precedence) + let merged_env = merge_maps(resolved_env.as_ref(), file_env.as_ref()); + + // Inject merged environment variables + for (key, value) in merged_env { + debug!("Injecting env var: {} (value redacted)", key); + cmd.env(key, value); } // Execute with timeout @@ -528,11 +584,14 @@ async fn execute_cli( /// /// If `inject_headers` is provided from a matching policy rule, headers are /// substituted from the sidecar's process environment and added to the request. -/// Injected headers take precedence over payload headers with the same name. +/// If `inject_headers_from_file` is provided, secrets are read from files and merged. +/// File-based headers take precedence over env-var-based headers for same keys. +/// All injected headers take precedence over payload headers with the same name. async fn execute_http_fetch( resource: &str, payload: Option<&ExecutePayload>, inject_headers: Option<&HashMap>, + inject_headers_from_file: Option<&HashMap>, ) -> Result<(ExecuteResult, String), String> { let client = reqwest::Client::new(); @@ -564,15 +623,33 @@ async fn execute_http_fetch( } } - // Inject headers from policy rule (these override payload headers) - if let Some(header_templates) = inject_headers { - let resolved_headers = substitute_env_vars_in_map(header_templates) - .map_err(|e| format!("Secret injection failed: {}", e))?; + // Resolve env-var-based headers + let resolved_headers = if let Some(header_templates) = inject_headers { + Some( + substitute_env_vars_in_map(header_templates) + .map_err(|e| format!("Secret injection failed: {}", e))?, + ) + } else { + None + }; + + // Resolve file-based headers + let file_headers = if let Some(file_templates) = inject_headers_from_file { + Some( + read_secrets_from_files(file_templates) + .map_err(|e| format!("File secret injection failed: {}", e))?, + ) + } else { + None + }; - for (key, value) in resolved_headers { - debug!("Injecting header: {} (value redacted)", key); - request = request.header(&key, &value); - } + // Merge headers (file-based takes precedence over env-var-based) + let merged_headers = merge_maps(resolved_headers.as_ref(), file_headers.as_ref()); + + // Inject merged headers (these override payload headers) + for (key, value) in merged_headers { + debug!("Injecting header: {} (value redacted)", key); + request = request.header(&key, &value); } let response = request diff --git a/src/http/mod.rs b/src/http/mod.rs index 32180bf..ff81796 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -481,6 +481,28 @@ async fn metrics_handler(State(state): State) -> String { state.policy_engine.rule_count() )); + // Secret injection metrics + output.push_str( + "# HELP predicate_authority_secret_injections_total Total secret injection operations\n", + ); + output.push_str("# TYPE predicate_authority_secret_injections_total counter\n"); + output.push_str(&format!( + "predicate_authority_secret_injections_total{{type=\"headers_from_env\"}} {}\n", + stats.headers_injected + )); + output.push_str(&format!( + "predicate_authority_secret_injections_total{{type=\"headers_from_file\"}} {}\n", + stats.headers_injected_from_file + )); + output.push_str(&format!( + "predicate_authority_secret_injections_total{{type=\"env_vars_from_env\"}} {}\n", + stats.env_vars_injected + )); + output.push_str(&format!( + "predicate_authority_secret_injections_total{{type=\"env_vars_from_file\"}} {}\n", + stats.env_vars_injected_from_file + )); + output } @@ -929,7 +951,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }]; let policy = PolicyEngine::with_rules(rules); // Disable SSRF protection for test URLs @@ -1029,7 +1053,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }]; let policy = PolicyEngine::with_rules(rules); policy.set_ssrf_protection(None); @@ -1107,7 +1133,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }, PolicyRule { name: "allow-fs".to_string(), @@ -1118,7 +1146,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }, ]; let policy = PolicyEngine::with_rules(rules); diff --git a/src/models/mod.rs b/src/models/mod.rs index e6fa0df..47f7bfc 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -229,10 +229,19 @@ pub struct PolicyRule { /// Values support environment variable substitution: `${VAR_NAME}` or `${VAR:-default}` #[serde(default, skip_serializing_if = "Option::is_none")] pub inject_headers: Option>, + /// Headers to inject from files. Key is header name, value is file path. + /// File contents are read at execution time (useful for large secrets like certificates). + /// File paths support environment variable substitution. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inject_headers_from_file: Option>, /// Environment variables to inject for cli.exec actions. /// Values support environment variable substitution: `${VAR_NAME}` or `${VAR:-default}` #[serde(default, skip_serializing_if = "Option::is_none")] pub inject_env: Option>, + /// Environment variables to inject from files. Key is env var name, value is file path. + /// File contents are read at execution time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inject_env_from_file: Option>, } /// Mandate claims (JWT payload) diff --git a/src/policy/mod.rs b/src/policy/mod.rs index c120b5a..5ceb8e7 100644 --- a/src/policy/mod.rs +++ b/src/policy/mod.rs @@ -381,7 +381,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, } } @@ -395,7 +397,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, } } @@ -446,7 +450,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }, sample_allow_rule(), ]); @@ -471,7 +477,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }, ]); @@ -492,7 +500,9 @@ mod tests { required_labels: vec!["mfa_verified".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }; let engine = PolicyEngine::with_rules(vec![rule]); @@ -517,7 +527,9 @@ mod tests { required_labels: vec!["mfa_verified".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }; let engine = PolicyEngine::with_rules(vec![rule]); @@ -563,7 +575,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }]); let request = SidecarAuthorizeRequest { @@ -593,7 +607,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }]); let request = SidecarAuthorizeRequest { @@ -622,7 +638,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }]); let request = SidecarAuthorizeRequest { @@ -650,7 +668,9 @@ mod tests { required_labels: vec![], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, }]); // Disable SSRF protection diff --git a/src/proof/mod.rs b/src/proof/mod.rs index 6bbd353..99ec413 100644 --- a/src/proof/mod.rs +++ b/src/proof/mod.rs @@ -53,6 +53,14 @@ pub struct DecisionStats { pub min_latency_us: Option, /// Maximum latency seen (microseconds) pub max_latency_us: Option, + /// Number of times headers were injected (env var based) + pub headers_injected: u64, + /// Number of times headers were injected from files + pub headers_injected_from_file: u64, + /// Number of times env vars were injected (env var based) + pub env_vars_injected: u64, + /// Number of times env vars were injected from files + pub env_vars_injected_from_file: u64, } impl DecisionStats { @@ -247,6 +255,27 @@ impl InMemoryProofLedger { self.stats.read().clone() } + /// Record secret injection events for metrics tracking + /// + /// # Arguments + /// * `headers_from_env` - Number of headers injected from env vars + /// * `headers_from_file` - Number of headers injected from files + /// * `env_vars_from_env` - Number of env vars injected from env vars + /// * `env_vars_from_file` - Number of env vars injected from files + pub fn record_injection( + &self, + headers_from_env: usize, + headers_from_file: usize, + env_vars_from_env: usize, + env_vars_from_file: usize, + ) { + let mut stats = self.stats.write(); + stats.headers_injected += headers_from_env as u64; + stats.headers_injected_from_file += headers_from_file as u64; + stats.env_vars_injected += env_vars_from_env as u64; + stats.env_vars_injected_from_file += env_vars_from_file as u64; + } + /// Get total event count (events currently in memory) pub fn event_count(&self) -> usize { self.events.read().len() diff --git a/src/secrets.rs b/src/secrets.rs index 79b4878..f10f722 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -144,6 +144,82 @@ pub fn validate_env_vars(template: &str) -> Vec { missing } +/// Read secret value from a file. +/// +/// The file path can contain environment variable references which are substituted first. +/// File contents are trimmed of trailing whitespace/newlines. +/// +/// # Arguments +/// +/// * `file_path_template` - Path to the file, may contain `${VAR}` patterns +/// +/// # Returns +/// +/// The file contents as a string, with trailing whitespace trimmed. +pub fn read_secret_from_file(file_path_template: &str) -> SubstitutionResult { + // First substitute any env vars in the path + let file_path = substitute_env_vars(file_path_template)?; + + // Read the file + let contents = std::fs::read_to_string(&file_path).map_err(|e| { + SecretSubstitutionError::SubstitutionFailed(format!( + "Failed to read secret file '{}': {}", + file_path, e + )) + })?; + + // Trim trailing whitespace (common with secret files) + Ok(contents.trim_end().to_string()) +} + +/// Read secrets from files and merge into a HashMap. +/// +/// Used for processing `inject_headers_from_file` and `inject_env_from_file`. +/// +/// # Arguments +/// +/// * `file_templates` - HashMap where keys are header/env names and values are file paths +/// +/// # Returns +/// +/// A new HashMap with file contents as values. +pub fn read_secrets_from_files( + file_templates: &HashMap, +) -> SubstitutionResult> { + let mut result = HashMap::with_capacity(file_templates.len()); + + for (key, file_path_template) in file_templates { + let value = read_secret_from_file(file_path_template)?; + result.insert(key.clone(), value); + } + + Ok(result) +} + +/// Merge two HashMaps, with the second taking precedence for duplicate keys. +/// +/// Used to merge `inject_headers` with `inject_headers_from_file`. +pub fn merge_maps( + base: Option<&HashMap>, + override_map: Option<&HashMap>, +) -> HashMap { + let mut result = HashMap::new(); + + if let Some(b) = base { + for (k, v) in b { + result.insert(k.clone(), v.clone()); + } + } + + if let Some(o) = override_map { + for (k, v) in o { + result.insert(k.clone(), v.clone()); + } + } + + result +} + #[cfg(test)] mod tests { use super::*; @@ -288,4 +364,93 @@ mod tests { std::env::remove_var("MY_VAR_123"); std::env::remove_var("_PRIVATE_VAR"); } + + #[test] + fn test_read_secret_from_file() { + // Create a temp file with secret content + let temp_path = "/tmp/test_secret_file.txt"; + std::fs::write(temp_path, "my-secret-value\n").unwrap(); + + let result = read_secret_from_file(temp_path).unwrap(); + assert_eq!(result, "my-secret-value"); // Trailing newline trimmed + + std::fs::remove_file(temp_path).ok(); + } + + #[test] + fn test_read_secret_from_file_with_env_var_path() { + let temp_path = "/tmp/test_secret_env_path.txt"; + std::fs::write(temp_path, "secret-from-env-path").unwrap(); + std::env::set_var("TEST_SECRET_PATH", temp_path); + + let result = read_secret_from_file("${TEST_SECRET_PATH}").unwrap(); + assert_eq!(result, "secret-from-env-path"); + + std::env::remove_var("TEST_SECRET_PATH"); + std::fs::remove_file(temp_path).ok(); + } + + #[test] + fn test_read_secret_from_file_not_found() { + let result = read_secret_from_file("/nonexistent/path/to/secret"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Failed to read")); + } + + #[test] + fn test_read_secrets_from_files() { + // Create temp files + std::fs::write("/tmp/test_header_1.txt", "header-value-1").unwrap(); + std::fs::write("/tmp/test_header_2.txt", "header-value-2\n").unwrap(); + + let mut file_map = HashMap::new(); + file_map.insert( + "X-Header-1".to_string(), + "/tmp/test_header_1.txt".to_string(), + ); + file_map.insert( + "X-Header-2".to_string(), + "/tmp/test_header_2.txt".to_string(), + ); + + let result = read_secrets_from_files(&file_map).unwrap(); + + assert_eq!(result.get("X-Header-1").unwrap(), "header-value-1"); + assert_eq!(result.get("X-Header-2").unwrap(), "header-value-2"); + + std::fs::remove_file("/tmp/test_header_1.txt").ok(); + std::fs::remove_file("/tmp/test_header_2.txt").ok(); + } + + #[test] + fn test_merge_maps() { + let mut base = HashMap::new(); + base.insert("key1".to_string(), "base1".to_string()); + base.insert("key2".to_string(), "base2".to_string()); + + let mut override_map = HashMap::new(); + override_map.insert("key2".to_string(), "override2".to_string()); + override_map.insert("key3".to_string(), "override3".to_string()); + + let result = merge_maps(Some(&base), Some(&override_map)); + + assert_eq!(result.get("key1").unwrap(), "base1"); + assert_eq!(result.get("key2").unwrap(), "override2"); // Overridden + assert_eq!(result.get("key3").unwrap(), "override3"); + } + + #[test] + fn test_merge_maps_with_none() { + let mut base = HashMap::new(); + base.insert("key1".to_string(), "value1".to_string()); + + // Base only + let result = merge_maps(Some(&base), None); + assert_eq!(result.len(), 1); + assert_eq!(result.get("key1").unwrap(), "value1"); + + // Neither + let result = merge_maps(None, None); + assert!(result.is_empty()); + } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index cfa62d3..9d888bd 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -73,7 +73,9 @@ async fn test_authorize_allow_rule() { resources: vec!["*".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, required_labels: vec![], }]; @@ -118,7 +120,9 @@ async fn test_authorize_deny_rule() { resources: vec!["*".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, required_labels: vec![], }]; @@ -281,7 +285,9 @@ async fn test_legacy_authorize_endpoint() { resources: vec!["*".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, required_labels: vec![], }]; @@ -318,7 +324,9 @@ async fn test_authorize_with_labels() { resources: vec!["*".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, required_labels: vec!["approved".to_string(), "verified".to_string()], }]; @@ -373,7 +381,9 @@ async fn test_local_mode_no_token_required() { resources: vec!["*".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, required_labels: vec![], }]; @@ -412,7 +422,9 @@ async fn test_local_idp_mode_requires_token() { resources: vec!["*".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, required_labels: vec![], }]; @@ -459,7 +471,9 @@ async fn test_local_idp_mode_invalid_token() { resources: vec!["*".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, required_labels: vec![], }]; @@ -565,7 +579,9 @@ async fn test_execute_mandate_not_found() { resources: vec!["*".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, required_labels: vec![], }]; @@ -617,7 +633,9 @@ async fn test_execute_with_stored_mandate() { resources: vec!["*".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, required_labels: vec![], }]; @@ -987,7 +1005,9 @@ async fn test_secret_injection_cli_exec() { resources: vec!["*".to_string()], max_delegation_depth: None, inject_headers: None, + inject_headers_from_file: None, inject_env: Some(inject_env), + inject_env_from_file: None, required_labels: vec![], }]; @@ -1131,7 +1151,9 @@ async fn test_secret_not_exposed_in_authorize_response() { resources: vec!["https://api.example.com/*".to_string()], max_delegation_depth: None, inject_headers: Some(inject_headers), + inject_headers_from_file: None, inject_env: None, + inject_env_from_file: None, required_labels: vec![], }]; @@ -1274,7 +1296,9 @@ async fn test_secret_validation_detects_missing_env_vars() { resources: vec!["*".to_string()], max_delegation_depth: None, inject_headers: Some(inject_headers), + inject_headers_from_file: None, inject_env: Some(inject_env), + inject_env_from_file: None, required_labels: vec![], }]; @@ -1306,3 +1330,134 @@ async fn test_secret_validation_detects_missing_env_vars() { // Cleanup std::env::remove_var("EXISTING_SECRET_VAR"); } + +/// Test file-based secret injection for CLI exec +#[tokio::test] +async fn test_secret_injection_from_file() { + use predicate_authorityd::mandate::MandateStore; + use predicate_authorityd::models::{MandateClaims, SignedMandate}; + use std::collections::HashMap; + use std::time::{SystemTime, UNIX_EPOCH}; + + // Create temp files with secrets + let secret_file_path = "/tmp/test_secret_injection.txt"; + std::fs::write(secret_file_path, "file_based_secret_value\n").unwrap(); + + // Create policy with inject_env_from_file + let mut inject_env_from_file = HashMap::new(); + inject_env_from_file.insert("FILE_SECRET".to_string(), secret_file_path.to_string()); + + // Also test env var based injection alongside file-based + let mut inject_env = HashMap::new(); + std::env::set_var("TEST_ENV_SECRET_FILE", "env_based_secret"); + inject_env.insert( + "ENV_SECRET".to_string(), + "${TEST_ENV_SECRET_FILE}".to_string(), + ); + + let rules = vec![PolicyRule { + name: "cli-with-file-injection".to_string(), + effect: predicate_authorityd::models::PolicyEffect::Allow, + principals: vec!["*".to_string()], + actions: vec!["cli.exec".to_string()], + resources: vec!["*".to_string()], + max_delegation_depth: None, + inject_headers: None, + inject_headers_from_file: None, + inject_env: Some(inject_env), + inject_env_from_file: Some(inject_env_from_file), + required_labels: vec![], + }]; + + let engine = PolicyEngine::new(); + engine.replace_rules(rules); + let mandate_store = MandateStore::new(); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + // Store a valid mandate + let mandate = SignedMandate { + token: "test-token".to_string(), + claims: MandateClaims { + mandate_id: "m_file_inject".to_string(), + principal_id: "agent:test".to_string(), + action: "cli.exec".to_string(), + resource: "sh".to_string(), + scopes: Vec::new(), + intent_hash: "hash123".to_string(), + state_hash: "state123".to_string(), + issued_at_epoch_s: now, + expires_at_epoch_s: now + 300, + delegated_by: None, + parent_mandate_id: None, + delegation_depth: 0, + delegation_chain_hash: Some("chain123".to_string()), + iss: Some("test".to_string()), + aud: Some("test".to_string()), + sub: Some("agent:test".to_string()), + iat: None, + exp: Some(now + 300), + nbf: None, + jti: Some("m_file_inject".to_string()), + }, + signature: "test-signature".to_string(), + }; + mandate_store.store(mandate); + + let state = AppState::new(engine, "local_only").with_mandate_store(mandate_store); + let app = create_router(state); + + // Execute command that echoes both env vars + let body = json!({ + "mandate_id": "m_file_inject", + "action": "cli.exec", + "resource": "sh", + "payload": { + "type": "cli_exec", + "command": "sh", + "args": ["-c", "echo FILE_SECRET=$FILE_SECRET ENV_SECRET=$ENV_SECRET"] + } + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/execute") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let resp: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + + assert_eq!(resp["success"], true); + + let stdout = resp["result"]["stdout"].as_str().unwrap(); + + // Verify both injection methods worked + assert!( + stdout.contains("file_based_secret_value"), + "Expected file_based_secret_value in stdout: {}", + stdout + ); + assert!( + stdout.contains("env_based_secret"), + "Expected env_based_secret in stdout: {}", + stdout + ); + + // Cleanup + std::env::remove_var("TEST_ENV_SECRET_FILE"); + std::fs::remove_file(secret_file_path).ok(); +}