Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions .github/workflows/release-fork.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
name: Release Fork

on:
push:
branches: [add-credential-injection]
workflow_dispatch:

concurrency:
group: release-fork-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: write
packages: write

env:
CARGO_TERM_COLOR: always

jobs:
build-cli:
name: Build CLI (linux-amd64)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable

- name: Install protoc
uses: arduino/setup-protoc@v3
with:
version: "29.x"
repo-token: ${{ secrets.GITHUB_TOKEN }}

- name: Cache cargo registry and build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-cli-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: cargo-cli-${{ runner.os }}-

- name: Build openshell CLI (release)
run: cargo build --release -p openshell-cli

- name: Package binary
run: |
mkdir -p dist
cp target/release/openshell dist/
cd dist
tar czf openshell-linux-amd64.tar.gz openshell
sha256sum openshell-linux-amd64.tar.gz > openshell-linux-amd64.tar.gz.sha256

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: openshell-linux-amd64
path: |
dist/openshell-linux-amd64.tar.gz
dist/openshell-linux-amd64.tar.gz.sha256

build-gateway:
name: Build gateway Docker image
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push gateway image
uses: docker/build-push-action@v6
with:
context: .
file: deploy/docker/Dockerfile.images
target: gateway
platforms: linux/amd64
push: true
tags: |
ghcr.io/htekdev/openshell-gateway:latest
ghcr.io/htekdev/openshell-gateway:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

release:
name: Create GitHub Release
needs: [build-cli]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Download CLI artifact
uses: actions/download-artifact@v4
with:
name: openshell-linux-amd64
path: dist/

- name: Create or update release
uses: softprops/action-gh-release@v2
with:
tag_name: fork-latest
name: "Fork Release (credential injection)"
body: |
Pre-built OpenShell fork with L7 credential injection for non-inference providers.

Branch: `add-credential-injection`
Commit: ${{ github.sha }}

**Changes:** Extends the L7 proxy to inject API credentials at the network layer
for arbitrary REST endpoints (Exa AI, Perplexity, YouTube, etc.).

**Gateway image:** `ghcr.io/htekdev/openshell-gateway:latest`
draft: false
prerelease: true
make_latest: false
files: |
dist/openshell-linux-amd64.tar.gz
dist/openshell-linux-amd64.tar.gz.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
57 changes: 55 additions & 2 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ All paths are relative to `crates/openshell-sandbox/src/`.
| `l7/relay.rs` | Protocol-aware bidirectional relay with per-request OPA evaluation |
| `l7/rest.rs` | HTTP/1.1 request/response parsing, body framing (Content-Length, chunked), deny response generation |
| `l7/provider.rs` | `L7Provider` trait and `L7Request`/`BodyLength` types |
| `credential_injector.rs` | L7 proxy credential injection for non-inference providers -- extracts injection configs from policy, resolves against provider env, injects credentials at the proxy layer |

## Startup and Orchestration

Expand Down Expand Up @@ -81,11 +82,13 @@ flowchart TD
- Priority 1: `--policy-rules` + `--policy-data` provided -- load OPA engine from local Rego file and YAML data file via `OpaEngine::from_files()`. Query `query_sandbox_config()` for filesystem/landlock/process settings. Network mode forced to `Proxy`.
- Priority 2: `--sandbox-id` + `--openshell-endpoint` provided -- fetch typed proto policy via `grpc_client::fetch_policy()`. Create OPA engine via `OpaEngine::from_proto()` using baked-in Rego rules. Convert proto to `SandboxPolicy` via `TryFrom`, which always forces `NetworkMode::Proxy` so that all egress passes through the proxy and the `inference.local` virtual host is always addressable.
- Neither present: return fatal error.
- Output: `(SandboxPolicy, Option<Arc<OpaEngine>>)`
- Output: `(SandboxPolicy, Option<Arc<OpaEngine>>, proto::SandboxPolicy)`

2. **Provider environment fetching**: If sandbox ID and endpoint are available, call `grpc_client::fetch_provider_environment()` to get a `HashMap<String, String>` of credential environment variables. On failure, log a warning and continue with an empty map.

3. **Binary identity cache**: If OPA engine is active, create `Arc<BinaryIdentityCache::new()>` for SHA256 TOFU enforcement.
3. **Credential injection extraction**: Scan the proto policy's network endpoints for `credential_injection` configs. For each match, look up the referenced credential in the provider environment, remove it from the env map (so it is not exposed to the sandbox process), and build a `CredentialInjector` that the L7 proxy will use to inject credentials at the network layer. See [Credential Injection](#credential-injection).

4. **Binary identity cache**: If OPA engine is active, create `Arc<BinaryIdentityCache::new()>` for SHA256 TOFU enforcement.

4. **Filesystem preparation** (`prepare_filesystem()`): For each path in `filesystem.read_write`, create the directory if it does not exist and `chown` to the configured `run_as_user`/`run_as_group`. Runs as the supervisor (root) before forking.

Expand Down Expand Up @@ -1001,6 +1004,56 @@ Implements `L7Provider` for HTTP/1.1:
5. If allowed (or audit mode): relay request to upstream and response back to client, then loop
6. If denied in enforce mode: send 403 and close the connection

## Credential Injection

**File:** `crates/openshell-sandbox/src/credential_injector.rs`

Credential injection extends the L7 proxy to inject API credentials at the network layer for arbitrary REST endpoints. This generalizes the `inference.local` credential injection pattern to any service in `network_policies`.

### Problem

When provider credentials are injected as environment variables, the agent process can read raw API keys from `process.env`. A prompt injection attack, malicious skill, or compromised dependency can read and exfiltrate these values. The network policy limits where a leaked key can be sent, but does not prevent the agent from reading it.

### Architecture

When an endpoint has a `credential_injection` configuration in the policy YAML:

1. **Sandbox startup** (`lib.rs`): `CredentialInjector::extract_from_policy()` scans the proto policy for `credential_injection` entries, cross-references them with the provider environment, removes the matched credentials from the child env map, and builds a `CredentialInjector` keyed by `(host, port)`.
2. **Proxy startup**: The `CredentialInjector` is passed through to `L7EvalContext` alongside the existing `SecretResolver`.
3. **Request relay** (`l7/rest.rs`): After the OPA policy allows a request, `relay_http_request_with_resolver()` applies credential injection:
- For header injection: strips any existing header with the same name (case-insensitive) and appends the injected header with the real credential.
- For query parameter injection: appends the credential as a URL query parameter.
4. **Agent process**: never sees the credential. It is not in `process.env` and not in any placeholder form.

### Injection types

| Type | YAML fields | Example |
|---|---|---|
| Header | `header: x-api-key` | `x-api-key: <value>` |
| Header + prefix | `header: Authorization`, `value_prefix: "Bearer "` | `Authorization: Bearer <value>` |
| Query parameter | `query_param: key` | URL appended with `?key=<value>` |

### Relationship to SecretResolver

`SecretResolver` and `CredentialInjector` serve different purposes:

| | SecretResolver | CredentialInjector |
|---|---|---|
| **Mechanism** | Placeholder rewriting | Direct injection |
| **Agent visibility** | Agent sees placeholder env vars | Agent sees nothing |
| **When applied** | All provider credentials (default) | Only credentials with `credential_injection` |
| **Auth header source** | Agent constructs the header using placeholder | Proxy adds the header from scratch |
| **Spoofing risk** | Agent could send placeholders to wrong endpoint | Proxy strips any existing header first |

Both are applied in `relay_http_request_with_resolver()`: `SecretResolver` rewrites first, then `CredentialInjector` injects.

### Validation rules

- `credential_injection` requires `protocol: rest` and `tls: terminate`
- Exactly one of `header` or `query_param` must be set
- `credential` and `provider` are required
- `value_prefix` is only valid with `header`

## Process Identity

### SHA256 TOFU (Trust-On-First-Use)
Expand Down
4 changes: 3 additions & 1 deletion crates/openshell-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ url = { workspace = true }
## Off by default so production builds have an empty registry.
## Enabled by e2e tests and during development.
dev-settings = []
## Use bundled protoc from protobuf-src instead of system protoc.
bundled-protoc = ["protobuf-src"]

[build-dependencies]
tonic-build = { workspace = true }
protobuf-src = { workspace = true }
protobuf-src = { workspace = true, optional = true }

[dev-dependencies]
tempfile = "3"
Expand Down
21 changes: 12 additions & 9 deletions crates/openshell-core/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}

// --- Protobuf compilation ---
// Use bundled protoc from protobuf-src. The system protoc (from apt-get)
// does not bundle the well-known type includes (google/protobuf/struct.proto
// etc.), so we must use protobuf-src which ships both the binary and the
// include tree.
// SAFETY: This is run at build time in a single-threaded build script context.
// No other threads are reading environment variables concurrently.
#[allow(unsafe_code)]
unsafe {
env::set_var("PROTOC", protobuf_src::protoc());
// Prefer PROTOC env var (e.g., from mise or system install) when available.
// Fall back to bundled protoc from protobuf-src if the feature is enabled.
if env::var("PROTOC").is_err() {
#[cfg(feature = "bundled-protoc")]
{
// SAFETY: This is run at build time in a single-threaded build script context.
// No other threads are reading environment variables concurrently.
#[allow(unsafe_code)]
unsafe {
env::set_var("PROTOC", protobuf_src::protoc());
}
}
}

let proto_files = [
Expand Down
Loading
Loading