Skip to content
Merged
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
154 changes: 154 additions & 0 deletions docs/security/least-privilege-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Just-in-time credentials (governance R9)

Forge can mint short-lived, per-tool-call credentials at the moment
of tool execution rather than passing a long-lived environment
variable through the whole session. This narrows the blast radius of
a leaked token: if the agent leaks a JIT AWS token, an attacker gets
15 minutes of a scope-down role, not the deployment's full role.

## Provider model

Each credential comes from a **Provider** (a plugin), configured
per-tool via a `CredentialSpec` in `forge.yaml`. At startup the
runner resolves every spec against the plugin registry; on every
matching tool call it calls the provider's `Materialize` to produce
a fresh set of env vars, merges them into the tool's subprocess
env, and (if the provider supports it) revokes the credential after
the call completes.

Built-in providers:

| Name | What it mints | Config keys |
|---------------------|-------------------------------------------------|-----------------------------------------------------------------------|
| `static` | Fixed env / headers from operator config | `env`, `headers`, `ttl` (informational) |
| `sts_assume_role` | Short-lived AWS creds via STS AssumeRole | `role_arn` (required), `session_name`, `external_id`, `duration`, `session_policy`, `region`, `endpoint` |

`static` is passthrough — useful as a bootstrap or in tests. Real
least-privilege posture comes from `sts_assume_role` (or a Vault
dynamic-secrets provider, tracked as a follow-up).

## Declaring specs in `forge.yaml`

```yaml
credentials:
# AWS CLI: 15-minute scoped read-only role, external-id tied to skill.
- tool: cli_execute
binary: aws
provider: sts_assume_role
spec:
role_arn: arn:aws:iam::123456789012:role/forge-skill-read
external_id: skill-alpha
session_name: forge-agent-jit
duration: 15m

# kubectl: static passthrough (until a Vault provider ships).
- tool: cli_execute
binary: kubectl
provider: static
spec:
env:
KUBECONFIG: /var/run/secrets/skill-alpha/kubeconfig
ttl: 1h
```

### Field reference

- **tool** — the tool name (e.g. `cli_execute`, `http_request`).
Empty matches every tool.
- **binary** — for `cli_execute` only, the binary being invoked.
Ignored on other tools.
- **provider** — the plugin name.
- **spec** — an object whose shape depends on the plugin.

Only the FIRST matching spec fires — order specs from most to
least specific.

## `sts_assume_role` details

The provider issues a single AWS STS AssumeRole POST signed with
SigV4. The source credentials (the identity Forge itself runs
under) come from `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` /
`AWS_SESSION_TOKEN` env vars — the standard AWS chain. Override
the source env-var names via `source_access_key` / `source_secret_key`
/ `source_token` in the spec if the operator runs multiple identities
side-by-side.

The `duration` field must be between 15 minutes and 12 hours (AWS
STS bounds). Below 15m gets rejected at startup, not silently
truncated.

`external_id` is threaded through to STS so operators can scope the
target role's trust policy to a specific skill: only the STS call
carrying the right external-id can assume it.

The generated `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and
`AWS_SESSION_TOKEN` are appended to the subprocess env — overriding
any same-name values from the operator's static passthrough. Any
AWS CLI (or SDK) the subprocess launches uses them automatically.

## Audit events

Two events fire per JIT credential lifecycle:

- `credential_issued` — after materialize, before the tool runs.
Fields: `provider`, `tool`, `binary`, `ttl`, `env_keys`,
`header_keys`, `duration_ms`.
- `credential_revoked` — after the tool completes (whether or not
the provider actually revokes). Fields: `provider`, `tool`,
`binary`. Carries `error` if revocation failed.
- `credential_failed` — when materialize fails (STS 403, network
error). The tool call is then aborted.

Payloads never contain the credential material itself — only the
key names, so SIEMs can validate that expected credentials are being
minted without exfiltrating secrets through the audit trail.

## Threat model

Solves:
- Long-lived deployment credentials in reach of a compromised skill.
- Cross-skill credential sharing (spec is per skill+tool+binary).
- Post-hoc audit of "which role did this action run as" via
`credential_issued` events.

Does not solve:
- Live-token exfiltration during the tool call itself (the token IS
in the subprocess env for the duration of the call).
- Provider-side compromise (a stolen STS AssumeRole call from the
Forge deployment role still works).
- Revocation-under-attack when the underlying provider is offline.

### Preventing credential leakage into the LLM (⚠️ operator action)

`cli_execute` merges the JIT env into the subprocess env — which
means a subprocess like `env`, or a misbehaving CLI that dumps its
own environment, will emit the credential material into stdout,
which the LLM then reads. This is the shape the `TestCLIExecute_
JITCredentialsInjectedIntoEnv` regression test uses to prove
injection works, but in production it's a footgun.

**Mitigation**: use the R4a `deny_output` skill guardrail (#209 /
`docs/security/policy-decisions.md`) to redact the material before
the LLM sees it. Example `SKILL.md` frontmatter:

```yaml
forge:
deny_output:
- pattern: 'AKIA[0-9A-Z]{16}'
action: redact
- pattern: 'aws_secret_access_key\s*=\s*\S+'
action: redact
```

Post-#209 the redact loop fires for output of any tool (not just
`cli_execute`), so the same pattern list covers `http_request`
response bodies that leak credentials in the headers echo of a
misconfigured API.

Combine JIT credentials (#215) with:
- R4a `deny_output` (#209) — redact credentials before LLM ingest,
- per-skill egress allow-lists (existing),
- audit signing (#213) + hash chaining (#212) — for tamper-evident
`credential_issued` events,

for full governance R9 posture.
32 changes: 32 additions & 0 deletions forge-cli/runtime/credentials_audit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package runtime

import (
"context"

"github.com/initializ/forge/forge-core/credentials"
coreruntime "github.com/initializ/forge/forge-core/runtime"
)

// auditSinkAdapter is the credentials.AuditSink implementation that
// routes to the AuditLogger. It's a small shim so the credentials
// package doesn't depend on runtime (which would cycle: agentspec
// -> credentials -> runtime -> agentspec).
type auditSinkAdapter struct {
logger *coreruntime.AuditLogger
}

// Compile-time assertion.
var _ credentials.AuditSink = (*auditSinkAdapter)(nil)

// Emit forwards each event through EmitFromContext so per-invocation
// correlation_id, task_id, sequence number, tenancy, and workflow
// tags auto-attach when the caller's ctx carries them.
func (a *auditSinkAdapter) Emit(ctx context.Context, event string, fields map[string]any) {
if a == nil || a.logger == nil {
return
}
a.logger.EmitFromContext(ctx, coreruntime.AuditEvent{
Event: event,
Fields: fields,
})
}
33 changes: 30 additions & 3 deletions forge-cli/runtime/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import (
_ "github.com/initializ/forge/forge-core/auth/providers/oidc"
"github.com/initializ/forge/forge-core/auth/providers/statictoken"
"github.com/initializ/forge/forge-core/compress"
"github.com/initializ/forge/forge-core/credentials"
_ "github.com/initializ/forge/forge-core/credentials/static" //nolint:revive // registers static provider via init()
_ "github.com/initializ/forge/forge-core/credentials/sts" //nolint:revive // registers sts_assume_role provider via init()
"github.com/initializ/forge/forge-core/llm"
"github.com/initializ/forge/forge-core/llm/oauth"
"github.com/initializ/forge/forge-core/llm/providers"
Expand Down Expand Up @@ -354,6 +357,24 @@ func (r *Runner) Run(ctx context.Context) error {
}
r.auditSigningKey = auditSigningKey

// R9 (#215) JIT credential injector. Resolves each declared
// CredentialSpec against the DefaultRegistry — imports of
// credentials/static and credentials/sts wire providers via init().
// Absent when the operator hasn't declared any specs (nil-safe:
// tools that hold this pointer treat nil as "no JIT").
var credInjector *credentials.Injector
if len(r.cfg.Config.Credentials) > 0 {
sink := &auditSinkAdapter{logger: auditLogger}
inj, err := credentials.NewInjector(ctx, credentials.DefaultRegistry, r.cfg.Config.Credentials, sink)
if err != nil {
return fmt.Errorf("resolving credentials: %w", err)
}
credInjector = inj
r.logger.Info("JIT credential injector wired", map[string]any{
"specs": len(r.cfg.Config.Credentials),
})
}

// Resolve TracingConfig early so we can thread it into the
// guardrail engine before the tracer provider itself is installed
// further down. ResolveTracingConfig is a pure config-resolution
Expand Down Expand Up @@ -660,7 +681,13 @@ func (r *Runner) Run(ctx context.Context) error {
default:
// Forge framework — build tool registry and use built-in LLM executor
reg := tools.NewRegistry()
if err := builtins.RegisterAll(reg); err != nil {
// R9: wire the JIT credential injector into http_request
// alongside cli_execute (further down). Nil injector →
// no-op inside the tool, so unsigned-cred deployments
// see pre-R9 behavior.
if err := builtins.RegisterAll(reg, builtins.Options{
HTTPCredentialInjector: credInjector,
}); err != nil {
r.logger.Warn("failed to register builtin tools", map[string]any{"error": err.Error()})
}

Expand Down Expand Up @@ -700,7 +727,7 @@ func (r *Runner) Run(ctx context.Context) error {
cliCfg.TimeoutSeconds = r.derivedCLIConfig.TimeoutHint
}
if len(cliCfg.AllowedBinaries) > 0 {
r.cliExecTool = clitools.NewCLIExecuteTool(cliCfg)
r.cliExecTool = clitools.NewCLIExecuteTool(cliCfg).WithCredentialInjector(credInjector)
if regErr := reg.Register(r.cliExecTool); regErr != nil {
r.logger.Warn("failed to register cli_execute", map[string]any{"error": regErr.Error()})
} else {
Expand All @@ -721,7 +748,7 @@ func (r *Runner) Run(ctx context.Context) error {
TimeoutSeconds: r.derivedCLIConfig.TimeoutHint,
WorkDir: r.cfg.WorkDir,
}
r.cliExecTool = clitools.NewCLIExecuteTool(cliCfg)
r.cliExecTool = clitools.NewCLIExecuteTool(cliCfg).WithCredentialInjector(credInjector)
if regErr := reg.Register(r.cliExecTool); regErr != nil {
r.logger.Warn("failed to register auto-derived cli_execute", map[string]any{"error": regErr.Error()})
} else {
Expand Down
67 changes: 67 additions & 0 deletions forge-cli/tools/cli_execute.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"time"

"github.com/initializ/forge/forge-core/credentials"
coretools "github.com/initializ/forge/forge-core/tools"
)

Expand All @@ -35,6 +36,11 @@ type CLIExecuteTool struct {
proxyURL string // egress proxy URL (e.g., "http://127.0.0.1:54321")
workDir string // resolved absolute workDir for path confinement
homeDir string // resolved $HOME for path confinement

// credInjector, when non-nil, mints JIT credentials per Execute
// call and merges them into the subprocess env. Governance R9.
// Nil injector → no-op, preserving pre-R9 behavior.
credInjector *credentials.Injector
}

// cliExecuteArgs is the JSON input schema for Execute.
Expand Down Expand Up @@ -104,6 +110,14 @@ func NewCLIExecuteTool(config CLIExecuteConfig) *CLIExecuteTool {
return t
}

// WithCredentialInjector attaches an R9 JIT-credential injector.
// Called by the runner at startup after resolving AgentSpec.Credentials.
// nil-safe: passing nil is equivalent to not calling this.
func (t *CLIExecuteTool) WithCredentialInjector(inj *credentials.Injector) *CLIExecuteTool {
t.credInjector = inj
return t
}

// Name returns the tool name.
func (t *CLIExecuteTool) Name() string { return "cli_execute" }

Expand Down Expand Up @@ -211,6 +225,37 @@ func (t *CLIExecuteTool) Execute(ctx context.Context, args json.RawMessage) (str
// Security check 6: Env isolation
cmd.Env = t.buildEnv(input.Binary)

// R9 JIT credentials: if the runner wired a credentials.Injector,
// mint fresh scoped-down creds now, merge them into the subprocess
// env, and defer revocation. Nil injector → no-op. Non-cli_execute
// spec matches are simply skipped by Injector.Materialize.
//
// Override semantics: if an operator lists a JIT key (e.g.
// AWS_ACCESS_KEY_ID) in env_passthrough AND has a JIT provider
// stamping the same name, the JIT value MUST win — otherwise the
// subprocess silently keeps the broader source creds (a privilege
// escalation). Go's os/exec dedups env keeping the last entry, but
// we don't want the security of the pipeline to depend on that: we
// explicitly strip conflicting keys BEFORE appending the JIT env.
// Regression test in cli_execute_creds_test.go.
if t.credInjector != nil {
rawArgs, _ := json.Marshal(input)
handle, err := t.credInjector.Materialize(ctx, "cli_execute", input.Binary, rawArgs)
if err != nil {
return "", fmt.Errorf("cli_execute: minting JIT credentials: %w", err)
}
if handle != nil {
defer func() { _ = handle.Close(ctx) }()
jitEnv := handle.Env()
if len(jitEnv) > 0 {
cmd.Env = stripEnvKeys(cmd.Env, jitEnv)
for k, v := range jitEnv {
cmd.Env = append(cmd.Env, k+"="+v)
}
}
}
}

// Stdin
if input.Stdin != "" {
cmd.Stdin = strings.NewReader(input.Stdin)
Expand Down Expand Up @@ -261,6 +306,28 @@ func (t *CLIExecuteTool) Availability() (available, missing []string) {
// SetProxyURL sets the egress proxy URL for subprocess env injection.
func (t *CLIExecuteTool) SetProxyURL(url string) { t.proxyURL = url }

// stripEnvKeys returns env minus any "KEY=…" entry whose KEY appears
// in override. Used before appending JIT credentials so a same-named
// env_passthrough entry can't shadow the scoped-down JIT value.
// Comparison is exact (case-sensitive) — matches Go's own env
// dedup behavior.
func stripEnvKeys(env []string, override map[string]string) []string {
if len(override) == 0 || len(env) == 0 {
return env
}
out := env[:0:len(env)]
for _, kv := range env {
eq := strings.IndexByte(kv, '=')
if eq > 0 {
if _, drop := override[kv[:eq]]; drop {
continue
}
}
out = append(out, kv)
}
return out
}

// buildEnv constructs an isolated environment with only PATH, HOME, LANG
// and explicitly configured passthrough variables. When workDir is set,
// HOME is overridden to workDir so subprocess ~ expansion stays confined.
Expand Down
Loading
Loading