diff --git a/.env.example b/.env.example index ff7aa23..361eb10 100644 --- a/.env.example +++ b/.env.example @@ -28,5 +28,13 @@ MCP_GATEWAY_TOKEN= # MCP_STRIPE_URL= # MCP_MEMORY_URL= +# ── Per-session human attribution (opt-in, sdk-k8s) ────────────────── +# Bind each session's AWS + Kubernetes actions to a named human instead +# of the anonymous tenant IRSA role. Unset = unattributed (default). +# Requires platform IAM/RBAC — see docs/attribution.md before enabling. +# FAB_OPERATOR=alice@acme.com +# FAB_SESSION_ROLE_ARN=arn:aws:iam:::role/ +# FAB_SESSION_DURATION=3600 # seconds, 900–3600 under role chaining + # ── Service credentials ────────────────────────────────────────────── # See .env.vault and docs/VAULT_SETUP.md diff --git a/CLAUDE.md b/CLAUDE.md index 7dafcbe..6dd154a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ Groups (`group` field): - `src/runtime.ts` + `src/runtimes/` — `AgentRuntime` interface + four implementations: `managed-agents.ts` against the REST API; `sdk.ts` against `@anthropic-ai/claude-agent-sdk` (API-billed); `sdk-k8s.ts` dispatching each role-session as its own isolated pod on the eks-agent-platform substrate; `claude-cli.ts` driving the `claude -p` subprocess per role session (subscription-billable via the user's existing Claude Code login). `src/runtimes/sdk-events.ts` holds the shared SDK message → `AgentEvent` translator; `src/runtimes/role-session.ts` is the in-pod entrypoint (`fab role-session`) that `sdk-k8s` dispatches. `src/k8s.ts` is a minimal `fetch`-based in-cluster Kubernetes client. `src/runtimes/index.ts` exports `createRuntime(api)` which resolves the transport from `FAB_RUNTIME` (default `managed-agents`). Parity trade-offs documented in [`docs/transports.md`](docs/transports.md). - `src/inference.ts` — the inference-backend seam. `resolveInferenceBackend()` reads `FAB_INFERENCE` (`api` default | `bedrock` | `anthropic-aws`); `resolveModelId()` maps canonical model ids to AWS Bedrock ids (the `api` and `anthropic-aws` backends pass canonical ids through). `inferenceEnv()` returns the per-backend env overlay (`CLAUDE_CODE_USE_BEDROCK` for Bedrock; `CLAUDE_CODE_USE_ANTHROPIC_AWS` + `ANTHROPIC_AWS_WORKSPACE_ID` for Claude Platform on AWS). Consumed only by the `sdk` runtime, which hosts the agent loop in-process and can therefore point the Agent SDK at Bedrock or Claude Platform on AWS. - `src/sandbox.ts` — the sandbox seam. `resolveSandboxMode()` reads `FAB_SANDBOX` (`cloud` default | `self-hosted`); `environmentConfig()` builds the `createEnvironment` config. Consumed by `deploy` and `getMemoryResource` — `self-hosted` points the Managed Agents environment at adopter-hosted workers and skips native Memory (unsupported with self-hosted sandboxes). +- `src/attribution.ts` — the per-session human-attribution seam. `resolveSessionIdentity()` reads `FAB_OPERATOR` / `FAB_SESSION_ROLE_ARN` / `FAB_SESSION_DURATION` (validating the operator STS-clean and the role ARN up front); `applySessionIdentity()` assumes a session role carrying the operator as STS `SourceIdentity` (AWS) and writes an impersonating kubeconfig (K8s), so the session's cloud actions bind to a named human. Computes both bindings before mutating env (no half-attributed state) and fails closed. Consumed by the in-pod `role-session` entrypoint and forwarded by the `sdk-k8s` dispatcher; a no-op when `FAB_OPERATOR` is unset (the default). See [`docs/attribution.md`](docs/attribution.md). - `src/team.ts` — barrel that aggregates per-phase modules under `src/team//.ts`. Each module declares ≤ 8 specialists. - `src/prompts.ts` — `buildSystemPrompt()` augmentation layer: appends journal, self-eval, repo, sprint, revision, and factory-production-standards preamble sections based on state + group. The Build Verification Protocol dispatches language-specific commands via `LANGUAGE_TOOLCHAIN[state.projectLanguage]`. - `src/standards.ts` — factory production policies. Two layers: (1) **public bar** loaded at module init from the vendored `standards/*.json` (`LANGUAGE_TOOLCHAIN` only at present; future structured facts join via the same loader); (2) **private choreography** declared here as markdown blobs: `FOUR_PHASE_CONTRACT`, `VERSION_CURRENCY_POLICY`, `EVIDENCE_CONTRACT`, `QUALITY_RUBRIC` (dimension weights + per-role assignments + N/A criteria — the depth behind the public dimension names), `IAC_BY_TARGET`, `PLATFORM_TENANT_CONTRACT`, `LLM_POLICY`, `PRODUCTION_BAR` (9 dimensions with the specific REJECT criteria), `COMMIT_PR_POLICY`, `MERGE_GATE_CONTRACT`, `FACTORY_PREAMBLE` (assembled), `CODE_GATE_ROLES`, `DOCS_GATE_ROLES`. The public bar JSON is the [Platform Reference](../nanohype/docs/platform-reference.md); external clients see the guardrails, only fab knows the depth. diff --git a/README.md b/README.md index aac15ac..da2395b 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ export FAB_K8S_RUNTIME_CLASS=gvisor # optional isolation dial Full details in [`docs/transports.md`](docs/transports.md#per-session-pod-isolation-sdk-k8s). +Set `FAB_OPERATOR=` to bind each dispatched session's AWS and Kubernetes actions to a named human (STS `SourceIdentity` + apiserver impersonation), instead of the anonymous tenant IRSA role — see [`docs/attribution.md`](docs/attribution.md). + ## Configuration ```sh diff --git a/__tests__/attribution.test.ts b/__tests__/attribution.test.ts new file mode 100644 index 0000000..cf2434d --- /dev/null +++ b/__tests__/attribution.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync, statSync } from 'node:fs'; +import { + resolveSessionIdentity, + sanitizeForSts, + assumeWithSourceIdentity, + writeImpersonationKubeconfig, + applySessionIdentity, + type CliRunner, + type SessionIdentity, +} from '../src/attribution.js'; + +const ROLE = 'arn:aws:iam::111111111111:role/fab-session'; + +/** A CliRunner that returns canned assume-role creds and records its args. */ +function fakeRunner(calls: string[][]): CliRunner { + return async (_file, args) => { + calls.push(args); + return { + stdout: JSON.stringify({ + Credentials: { + AccessKeyId: 'AKIAFAKE', + SecretAccessKey: 'secret', + SessionToken: 'token', + Expiration: '2026-06-11T12:00:00Z', + }, + }), + }; + }; +} + +describe('resolveSessionIdentity', () => { + it('returns null when no operator is set (unattributed default)', () => { + expect(resolveSessionIdentity({})).toBeNull(); + }); + + it('throws when an operator is set but no session role', () => { + expect(() => resolveSessionIdentity({ FAB_OPERATOR: 'alice@acme.com' })).toThrow(/FAB_SESSION_ROLE_ARN/); + }); + + it('resolves operator + role with the default duration', () => { + expect(resolveSessionIdentity({ FAB_OPERATOR: 'alice@acme.com', FAB_SESSION_ROLE_ARN: ROLE })).toEqual({ + operator: 'alice@acme.com', + roleArn: ROLE, + durationSeconds: 3600, + }); + }); + + it('honors a custom in-range duration', () => { + const id = resolveSessionIdentity({ + FAB_OPERATOR: 'alice@acme.com', + FAB_SESSION_ROLE_ARN: ROLE, + FAB_SESSION_DURATION: '7200', + }); + expect(id?.durationSeconds).toBe(7200); + }); + + it('rejects an out-of-range duration', () => { + expect(() => + resolveSessionIdentity({ + FAB_OPERATOR: 'alice@acme.com', + FAB_SESSION_ROLE_ARN: ROLE, + FAB_SESSION_DURATION: '60', + }), + ).toThrow(/900–43200/); + }); + + it('rejects a non-decimal duration (hex / float / garbage), not just out-of-range', () => { + for (const bad of ['soon', '3600.5', '0x384', '3600abc']) { + expect(() => + resolveSessionIdentity({ + FAB_OPERATOR: 'alice@acme.com', + FAB_SESSION_ROLE_ARN: ROLE, + FAB_SESSION_DURATION: bad, + }), + ).toThrow(/900–43200/); + } + }); + + it('rejects a session role that is not an IAM role ARN', () => { + expect(() => + resolveSessionIdentity({ FAB_OPERATOR: 'alice@acme.com', FAB_SESSION_ROLE_ARN: 'not-an-arn' }), + ).toThrow(/not an IAM role ARN/); + // a role ARN from another partition (GovCloud) is still accepted + expect( + resolveSessionIdentity({ + FAB_OPERATOR: 'alice@acme.com', + FAB_SESSION_ROLE_ARN: 'arn:aws-us-gov:iam::111111111111:role/fab-session', + })?.roleArn, + ).toBe('arn:aws-us-gov:iam::111111111111:role/fab-session'); + }); + + it('rejects an operator that is not STS-clean (so AWS == K8s binding)', () => { + expect(() => resolveSessionIdentity({ FAB_OPERATOR: 'alice smith', FAB_SESSION_ROLE_ARN: ROLE })).toThrow( + /A-Za-z0-9/, + ); + expect(() => resolveSessionIdentity({ FAB_OPERATOR: 'x', FAB_SESSION_ROLE_ARN: ROLE })).toThrow(/2–64/); + }); +}); + +describe('sanitizeForSts', () => { + it('passes an email through (already STS-valid)', () => { + expect(sanitizeForSts('alice@acme.com', 'fallback')).toBe('alice@acme.com'); + }); + it('replaces disallowed characters and trims edge dashes', () => { + expect(sanitizeForSts('a b/c:d', 'fallback')).toBe('a-b-c-d'); + expect(sanitizeForSts('//xy//', 'fallback')).toBe('xy'); + }); + it('bounds length to 64', () => { + expect(sanitizeForSts('a'.repeat(200), 'fallback')).toHaveLength(64); + }); + it('falls back when the value sanitizes to fewer than 2 chars', () => { + expect(sanitizeForSts('///', 'fab-session')).toBe('fab-session'); + expect(sanitizeForSts('/a/', 'fab-session')).toBe('fab-session'); + }); +}); + +describe('assumeWithSourceIdentity', () => { + const id: SessionIdentity = { operator: 'alice@acme.com', roleArn: ROLE, durationSeconds: 3600 }; + + it('calls aws sts assume-role with the operator as SourceIdentity and maps the creds', async () => { + const calls: string[][] = []; + const creds = await assumeWithSourceIdentity(id, 'fab-build', fakeRunner(calls)); + expect(creds).toEqual({ + AWS_ACCESS_KEY_ID: 'AKIAFAKE', + AWS_SECRET_ACCESS_KEY: 'secret', + AWS_SESSION_TOKEN: 'token', + }); + const args = calls[0]; + expect(args.slice(0, 2)).toEqual(['sts', 'assume-role']); + expect(args[args.indexOf('--source-identity') + 1]).toBe('alice@acme.com'); + expect(args[args.indexOf('--role-arn') + 1]).toBe(ROLE); + expect(args[args.indexOf('--duration-seconds') + 1]).toBe('3600'); + }); + + it('throws when STS returns no credentials', async () => { + const empty: CliRunner = async () => ({ stdout: JSON.stringify({}) }); + await expect(assumeWithSourceIdentity(id, 'fab-build', empty)).rejects.toThrow(/no usable credentials/); + }); +}); + +describe('writeImpersonationKubeconfig', () => { + it('writes a kubeconfig that impersonates the operator via the SA token', () => { + const path = writeImpersonationKubeconfig('alice@acme.com'); + const body = readFileSync(path, 'utf8'); + expect(body).toContain('as: "alice@acme.com"'); + expect(body).toContain('tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token'); + expect(body).toContain('server: https://kubernetes.default.svc'); + }); + + it('writes the kubeconfig owner-only (0600), not group/world readable', () => { + const path = writeImpersonationKubeconfig('alice@acme.com'); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); +}); + +describe('applySessionIdentity', () => { + it('returns null and touches nothing when unattributed', async () => { + const env: NodeJS.ProcessEnv = {}; + const id = await applySessionIdentity('fab-build', env, fakeRunner([])); + expect(id).toBeNull(); + expect(env.AWS_ACCESS_KEY_ID).toBeUndefined(); + expect(env.KUBECONFIG).toBeUndefined(); + }); + + it('exports SourceIdentity creds and a kubeconfig when an operator is set', async () => { + const env: NodeJS.ProcessEnv = { FAB_OPERATOR: 'alice@acme.com', FAB_SESSION_ROLE_ARN: ROLE }; + const id = await applySessionIdentity('fab-build', env, fakeRunner([])); + expect(id?.operator).toBe('alice@acme.com'); + expect(env.AWS_ACCESS_KEY_ID).toBe('AKIAFAKE'); + expect(env.AWS_SESSION_TOKEN).toBe('token'); + expect(env.KUBECONFIG).toMatch(/config$/); + expect(readFileSync(env.KUBECONFIG as string, 'utf8')).toContain('as: "alice@acme.com"'); + }); + + it('drops every other credential-source env var so only the assumed creds remain', async () => { + const env: NodeJS.ProcessEnv = { + FAB_OPERATOR: 'alice@acme.com', + FAB_SESSION_ROLE_ARN: ROLE, + AWS_ROLE_ARN: 'arn:aws:iam::1:role/tenant', + AWS_WEB_IDENTITY_TOKEN_FILE: '/var/run/secrets/eks.amazonaws.com/serviceaccount/token', + AWS_ROLE_SESSION_NAME: 'botocore-session', + AWS_CONTAINER_CREDENTIALS_FULL_URI: 'http://169.254.170.23/v1/credentials', + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: '/v2/credentials', + AWS_PROFILE: 'default', + AWS_SHARED_CREDENTIALS_FILE: '/root/.aws/credentials', + AWS_CONFIG_FILE: '/root/.aws/config', + }; + await applySessionIdentity('fab-build', env, fakeRunner([])); + for (const key of [ + 'AWS_ROLE_ARN', + 'AWS_WEB_IDENTITY_TOKEN_FILE', + 'AWS_ROLE_SESSION_NAME', + 'AWS_CONTAINER_CREDENTIALS_FULL_URI', + 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI', + 'AWS_PROFILE', + 'AWS_SHARED_CREDENTIALS_FILE', + 'AWS_CONFIG_FILE', + ]) { + expect(env[key]).toBeUndefined(); + } + expect(env.AWS_ACCESS_KEY_ID).toBe('AKIAFAKE'); + }); + + it('leaves env untouched when the assume-role fails (no half-attributed state)', async () => { + const failing: CliRunner = async () => { + throw new Error('sts unavailable'); + }; + const env: NodeJS.ProcessEnv = { + FAB_OPERATOR: 'alice@acme.com', + FAB_SESSION_ROLE_ARN: ROLE, + AWS_ROLE_ARN: 'arn:aws:iam::1:role/tenant', + AWS_WEB_IDENTITY_TOKEN_FILE: '/var/run/secrets/eks.amazonaws.com/serviceaccount/token', + AWS_ROLE_SESSION_NAME: 'botocore-session', + }; + await expect(applySessionIdentity('fab-build', env, failing)).rejects.toThrow(/sts unavailable/); + // Both bindings are computed before any env mutation, so a throw leaves env + // pristine: no assumed creds, no kubeconfig, and the pod IRSA vars survive. + expect(env.AWS_ACCESS_KEY_ID).toBeUndefined(); + expect(env.KUBECONFIG).toBeUndefined(); + expect(env.AWS_ROLE_ARN).toBe('arn:aws:iam::1:role/tenant'); + expect(env.AWS_WEB_IDENTITY_TOKEN_FILE).toBe('/var/run/secrets/eks.amazonaws.com/serviceaccount/token'); + expect(env.AWS_ROLE_SESSION_NAME).toBe('botocore-session'); + }); +}); diff --git a/__tests__/role-session.test.ts b/__tests__/role-session.test.ts index cdd2209..85a19ec 100644 --- a/__tests__/role-session.test.ts +++ b/__tests__/role-session.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import type { AgentEvent } from '../src/types.js'; import type { ParsedArgs } from '../src/args.js'; import { executeRoleSession, serializeEvent, streamEventsToJsonl } from '../src/runtimes/role-session.js'; +import { SdkRuntime } from '../src/runtimes/sdk.js'; +import { TEAM } from '../src/team.js'; function args(overrides: Partial = {}): ParsedArgs { return { command: 'role-session', sub: '', positional: [], flags: {}, ...overrides }; @@ -104,4 +106,32 @@ describe('executeRoleSession', () => { vi.stubEnv('FAB_MESSAGE', 'ship the thing'); await expect(executeRoleSession(args())).rejects.toThrow(/also-not-real/); }); + + it('fails closed — exit 1, runtime never invoked — when attribution setup throws', async () => { + vi.stubEnv('FAB_ROLE', TEAM[0].role); + vi.stubEnv('FAB_MESSAGE', 'ship it'); + // An invalid operator throws in resolveSessionIdentity, before any aws call, + // so the failure is deterministic and offline. + vi.stubEnv('FAB_OPERATOR', 'not a valid sts identity'); + vi.stubEnv('FAB_SESSION_ROLE_ARN', 'arn:aws:iam::000000000000:role/fab-session'); + const runRoleSession = vi.spyOn(SdkRuntime.prototype, 'runRoleSession'); + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const code = await executeRoleSession(args()); + expect(code).toBe(1); + expect(runRoleSession).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledWith(expect.stringMatching(/attribution setup failed/)); + }); + + it('runs the role session through the runtime when unattributed', async () => { + vi.stubEnv('FAB_ROLE', TEAM[0].role); + vi.stubEnv('FAB_MESSAGE', 'ship it'); + vi.stubEnv('FAB_OPERATOR', undefined); + const runRoleSession = vi + .spyOn(SdkRuntime.prototype, 'runRoleSession') + .mockResolvedValue({ events: asStream([idle]) } as never); + vi.spyOn(process.stdout, 'write').mockReturnValue(true); + const code = await executeRoleSession(args()); + expect(code).toBe(0); + expect(runRoleSession).toHaveBeenCalledTimes(1); + }); }); diff --git a/__tests__/sdk-k8s.test.ts b/__tests__/sdk-k8s.test.ts index e1504d1..1f1c01a 100644 --- a/__tests__/sdk-k8s.test.ts +++ b/__tests__/sdk-k8s.test.ts @@ -72,6 +72,26 @@ describe('buildAgentSandboxManifest', () => { expect(manifest.spec.env).toContainEqual({ name: 'AWS_REGION', value: 'us-east-1' }); }); + it('forwards the per-session attribution vars onto the session pod env', () => { + vi.stubEnv('FAB_OPERATOR', 'alice@acme.com'); + vi.stubEnv('FAB_SESSION_ROLE_ARN', 'arn:aws:iam::111111111111:role/fab-session'); + vi.stubEnv('FAB_SESSION_DURATION', '7200'); + const manifest = buildAgentSandboxManifest('go-engineer', 'x', cfg); + expect(manifest.spec.env).toContainEqual({ name: 'FAB_OPERATOR', value: 'alice@acme.com' }); + expect(manifest.spec.env).toContainEqual({ + name: 'FAB_SESSION_ROLE_ARN', + value: 'arn:aws:iam::111111111111:role/fab-session', + }); + expect(manifest.spec.env).toContainEqual({ name: 'FAB_SESSION_DURATION', value: '7200' }); + }); + + it('omits attribution vars when unset (unattributed default)', () => { + vi.stubEnv('FAB_OPERATOR', undefined); + vi.stubEnv('FAB_SESSION_ROLE_ARN', undefined); + const manifest = buildAgentSandboxManifest('go-engineer', 'x', cfg); + expect((manifest.spec.env ?? []).some((e) => e.name === 'FAB_OPERATOR')).toBe(false); + }); + it('sets runtimeClassName when the config carries one', () => { vi.stubEnv('FAB_INFERENCE', undefined); vi.stubEnv('AWS_REGION', undefined); diff --git a/docs/attribution.md b/docs/attribution.md new file mode 100644 index 0000000..f63e9a8 --- /dev/null +++ b/docs/attribution.md @@ -0,0 +1,216 @@ +# Per-session human attribution + +By default every fab session acts as the pod's tenant IRSA role — so every +Bedrock call and every `aws` / `kubectl` the agent runs is bound to a role, not +a person. That is the platform default, and it is exactly the gap an evidence +engine surfaces: _"a production action that traces to no named human."_ + +This is the opt-in that closes it. Name the human a session acts for, and fab +carries that identity into the cloud record so the action attributes to a +person — across both AWS and Kubernetes. It binds the actions of a _cooperating_ +session: an attribution mechanism for the normal path, not a containment control +against a hostile or prompt-injected agent — see [Threat model](#threat-model). + +Implemented in [`src/attribution.ts`](../src/attribution.ts); wired into the +in-pod `role-session` entrypoint and forwarded by the `sdk-k8s` dispatcher. + +## How it works + +Two mechanisms, one operator (`$FAB_OPERATOR`): + +| Cloud | Mechanism | What the record carries | Crossbearing binding | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| **AWS** | Assume a session role with the operator as STS `SourceIdentity`, using the pod's IRSA creds as the caller; export the temp creds | CloudTrail `userIdentity.sessionContext...sourceIdentity = ` on the Bedrock `InvokeModel` call **and** every `aws` the Bash tool runs | `AttrSTSSourceIdentity` (strongest) | +| **Kubernetes** | Point `kubectl` at a kubeconfig that authenticates with the SA token but impersonates the operator | apiserver audit `impersonatedUser.username = ` | `AttrK8sImpersonation` | + +Because the assumed credentials are exported into the process environment, the +Agent SDK's Bedrock inference call inherits them too — so even the model call is +attributed in CloudTrail. + +**Why SourceIdentity and not Bedrock `requestMetadata`?** The Agent SDK does not +expose the `InvokeModel` request, so fab cannot stamp Bedrock `requestMetadata` +from this path (that would require routing inference through a ModelGateway). +SourceIdentity rides the credentials the SDK already resolves from the standard +chain, and it is crossbearing's strongest binding — it attributes the agent's +`aws` and `kubectl` tool-call records, which crossbearing actually corroborates. +(The `InvokeModel` call carries the same SourceIdentity in CloudTrail too, but +that's a side effect — crossbearing's corroborated findings come from the +tool-call records, not the model call.) + +**No new dependency.** Like the `claude-cli` runtime, this shells to a CLI +already in the agent image (`aws`) via `node:child_process`. + +## Configuration + +| Env var | Required | Meaning | +| ---------------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `FAB_OPERATOR` | — | the named human this session acts for. **Unset = unattributed** (the default). | +| `FAB_SESSION_ROLE_ARN` | when `FAB_OPERATOR` is set | the role assumed with the operator as `SourceIdentity`. | +| `FAB_SESSION_DURATION` | no | seconds the assumed creds live (default 3600). Validates 900–43200, but [role chaining caps the shipped path at 3600](#duration-and-the-role-chaining-cap). | + +The `sdk-k8s` dispatcher forwards all three onto the session pod. Set them on the +fab dispatcher (e.g. `FAB_OPERATOR=alice@acme.com`) and every dispatched session +attributes to that human. + +**Fail-closed:** if `FAB_OPERATOR` is set but the assume-role fails, the session +aborts (exit 1) rather than run as the tenant role — running unattributed after +a human was named would silently strip the binding the evidence depends on. + +## Required platform setup (not in fab) + +The IAM and RBAC live on the platform side. The session role and the +`impersonate` grant must exist before `FAB_OPERATOR` is set. + +### 1. Session role trust policy + +Let the tenant IRSA role assume the session role **and set SourceIdentity**: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { "AWS": "arn:aws:iam:::role/--tenant" }, + "Action": ["sts:AssumeRole", "sts:SetSourceIdentity"] + } + ] +} +``` + +Give the session role the permissions the agent actually needs (e.g. the same +or a subset of the tenant role's). **Because the assumed credentials also serve +the inference call, the session role must include `bedrock:InvokeModel`** (and +`bedrock:InvokeModelWithResponseStream` / `bedrock:Converse` if used) — once the +static creds are in the environment they fully replace the pod's IRSA role for +every AWS call, the model call included. Keep the permissions tight: the session +role must **not** grant broad `sts:AssumeRole` / `sts:AssumeRoleWithWebIdentity` +(a chain to a role outside the `SourceIdentity`-required trust set would drop the +operator — see [Threat model](#threat-model)); deny `sts:*` unless a specific +downstream assume is required. Set its `MaxSessionDuration` ≥ +`FAB_SESSION_DURATION` — but note STS role chaining caps the assumed session at +3600s regardless (see [Duration and the role-chaining cap](#duration-and-the-role-chaining-cap)). + +### 2. Kubernetes impersonation RBAC + +Let the session pod's ServiceAccount impersonate the operator — scoped to the +specific user(s), never `impersonate *`: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: { name: impersonate-operators } +rules: + - apiGroups: [''] + resources: ['users'] + verbs: ['impersonate'] + resourceNames: ['alice@acme.com'] # the human(s) sessions may act as +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: { name: session-impersonate } +roleRef: { apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: impersonate-operators } +subjects: + - { kind: ServiceAccount, name: , namespace: } +``` + +The operator (`alice@acme.com`) then needs its own RBAC for whatever the agent +does as them in the cluster. + +**`FAB_OPERATOR` must be byte-identical** to the `resourceNames` entry in the +`impersonate` ClusterRole _and_ to the operator's own RBAC subject name. The AWS +side accepts any STS-valid string, but the apiserver only authorizes the +impersonation when the string matches the grant exactly — a case, `+tag`, or +trailing-dot mismatch leaves AWS attributed while every `kubectl` is denied (and, +per the "only successful actions are recorded" limitation below, those denials +leave no audit record), silently splitting the binding the design promises is one +human. Use a canonical form (a lowercased email) on all three. + +## How crossbearing consumes it + +- **CloudTrail** records the agent's `aws` calls with `SourceIdentity=` + → crossbearing binds the session via `AttrSTSSourceIdentity`. +- **K8s audit** records the agent's `kubectl` actions with + `impersonatedUser=` → `AttrK8sImpersonation`. +- The **claim** side comes separately from the Bedrock model-invocation log (the + `toolUse` blocks). Crossbearing joins claims to records by **time window + + operation match** (it does _not_ join on `requestId`), so the corroborated + finding inherits the human from the matched record's binding above. + +Result: the agent's corroborated `aws`/`kubectl` actions attribute to a named +human instead of collapsing to one anonymous IRSA role — the "after" state of +the divergence demo. (Crossbearing corroborates the agent's _tool calls_, not +the `InvokeModel` call, even though that call also carries the SourceIdentity.) + +## Threat model + +This is an **attribution** mechanism for the normal path: it binds the actions of +a _cooperating_ session to a named human and puts the operator on the audit +record. It is **not** an anti-spoofing or containment control against a hostile +or prompt-injected agent. The agent's tool calls run in an unrestricted shell +(the `sdk` runtime drives Bash under `bypassPermissions`), so an agent that +chooses to can step outside both bindings: + +- **Kubernetes** — `unset KUBECONFIG` (or pointing `kubectl` straight at + `https://kubernetes.default.svc` with the still-mounted ServiceAccount token) + drops the impersonation header; calls then authenticate as the bare + ServiceAccount with no `impersonatedUser`. +- **AWS** — the pod's IRSA web-identity token file stays mounted, so the agent + can re-authenticate as the tenant role directly. A _second_ `aws sts +assume-role` cannot **drop or change** the operator, because STS + `SourceIdentity` is sticky and immutable once set — but that only holds while + the role chain stays inside trust policies that require it. + +"Strongest binding" in this doc is **relative** to the Bedrock `requestMetadata` +alternative — not absolute proof that the human could not have been a different +one. + +For genuine tamper-resistance, the platform must close those fallbacks: + +- The session ServiceAccount holds **no direct RBAC** — only `impersonate` on the + named operator(s) — so dropping `KUBECONFIG` yields an identity that can do + nothing. +- The session role grants **no broad `sts:AssumeRole`** (ideally `sts:*` denied), + so the agent cannot chain to a role outside the `SourceIdentity`-required trust + set. +- Optionally, a `ValidatingAdmissionPolicy` or an audit alert flags + un-impersonated ServiceAccount calls as the backstop. + +## Duration and the role-chaining cap + +The caller fab uses to assume the session role is the pod's own IRSA-assumed +role, so the assume is STS **role chaining**. AWS caps a chained session at **1 +hour** regardless of the session role's `MaxSessionDuration`, and STS rejects any +`--duration-seconds` above 3600. So although `FAB_SESSION_DURATION` validates up +to 43200, any value above **3600** fails closed at assume-role time on the +shipped path. Keep `FAB_SESSION_DURATION` ≤ 3600, or add credential refresh +(re-assume at ~80% of the duration) if a session must outlive one hour. + +## Limitations / next steps + +- **Process-wide operator.** `FAB_OPERATOR` is one human per dispatcher process; + every session it dispatches attributes to that same human. A production + implementation should thread the _requesting_ human per workflow/request onto + the `AgentSandbox` spec (e.g. a `spec.operator` field) rather than a single + env var. +- **Credential TTL is a hard cliff (fail-safe, not fail-open).** Once the static + creds are in the environment the chain does **not** fall back to the pod's + IRSA role — so when they expire the next AWS/Bedrock/`kubectl` call fails hard + with `ExpiredToken` rather than silently reverting to the unattributed role. + That's the right safety property, but a session running past + `FAB_SESSION_DURATION` dies mid-task. Set the duration to cover the worst-case + wall-clock (and the role's `MaxSessionDuration` to match), or add credential + refresh (re-assume at ~80% of the duration and re-export the creds). +- **Only successful actions are recorded.** Crossbearing's K8s ingester records + only `ResponseComplete` events with `responseStatus.code < 400` (a denied + request must not corroborate a claim of success). So an impersonated `kubectl` + the operator's own RBAC denies leaves no record — the operator still needs + their own RBAC for whatever the agent does as them. +- **Single-use pod assumed.** The exported creds live in `process.env` for the + session lifetime (inherited by every Bash subprocess) and the temp kubeconfig + is not cleaned up — fine because each `AgentSandbox` pod is single-use and + torn down. If this entrypoint is ever reused for multiple operators in one + long-lived process, scope creds per-run and remove the kubeconfig between runs. +- **Bedrock `requestMetadata`** remains unreachable from the Agent SDK path; if + inference moves behind a ModelGateway, stamping a `session`/`operator` tag on + `InvokeModel` becomes a second, claim-side attribution channel. diff --git a/docs/transports.md b/docs/transports.md index e6b34d5..c80fca3 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -127,7 +127,7 @@ Paired with `FAB_INFERENCE=bedrock` this is the regulated-enterprise end state: | `FAB_K8S_PLATFORM` | — | The Platform the role-sessions run under; its tenant IRSA role scopes their Bedrock access | | `FAB_K8S_RUNTIME_CLASS` | unset | RuntimeClass for the session pod — `gvisor` or `kata` for kernel-level isolation | -`FAB_INFERENCE` and `AWS_REGION` are forwarded onto each session pod so the in-pod loop infers against the same backend as the dispatcher. +`FAB_INFERENCE` and `AWS_REGION` are forwarded onto each session pod so the in-pod loop infers against the same backend as the dispatcher. The per-session human-attribution vars `FAB_OPERATOR`, `FAB_SESSION_ROLE_ARN`, and `FAB_SESSION_DURATION` are forwarded too — set `FAB_OPERATOR` on the dispatcher and every dispatched session binds its AWS + Kubernetes actions to that named human. Unset = unattributed (the default). See [`docs/attribution.md`](attribution.md) for the mechanism and the platform IAM/RBAC it requires. The operator runs the session pods in the Platform's tenant namespace (`tenants-`), distinct from the namespace the `AgentSandbox` CRs live in. `deploy/rbac.yaml` grants fab's ServiceAccount both — the AgentSandbox + Platform reads in the management namespace, the pod-log reads in the tenant namespace. fab also needs to trust the cluster's API-server CA: set `NODE_EXTRA_CA_CERTS` to `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt` on its pod. diff --git a/src/attribution.ts b/src/attribution.ts new file mode 100644 index 0000000..c6db649 --- /dev/null +++ b/src/attribution.ts @@ -0,0 +1,301 @@ +/** + * Per-session human attribution. + * + * By default every fab session — and so every Bedrock call and every `aws` / + * `kubectl` the agent runs — acts as the pod's tenant IRSA role, bound to no + * named human. That is the platform default, and it is exactly the + * "production action that traces to no named human" gap an evidence engine + * (crossbearing) surfaces. This module is the opt-in that closes it: name the + * human a session acts for, and carry that identity into the cloud record so + * the action attributes to a person. + * + * Two mechanisms, one operator: + * + * - **AWS — STS SourceIdentity.** Assume a session role carrying the operator + * as `SourceIdentity`, using the pod's own IRSA credentials as the caller, + * and export the temporary credentials. Every subsequent AWS call — the + * Bedrock `InvokeModel` inference call AND any `aws` the Bash tool runs — is + * recorded in CloudTrail under `SourceIdentity=`. This is the + * strongest binding crossbearing reads (`AttrSTSSourceIdentity`). + * - **Kubernetes — impersonation.** Point `kubectl` at a kubeconfig that + * authenticates with the pod's ServiceAccount token but impersonates the + * operator, so the apiserver records the operator in the audit log's + * `impersonatedUser` field (`AttrK8sImpersonation`) — the same human as the + * AWS side. + * + * Why SourceIdentity and not Bedrock `requestMetadata`: the Agent SDK does not + * expose the `InvokeModel` request, so fab cannot stamp Bedrock + * `requestMetadata` from this path. SourceIdentity is reachable (it rides the + * credentials, which the SDK resolves from the standard chain) and it is + * crossbearing's strongest binding — it attributes the agent's `aws` and + * `kubectl` tool-call records, which crossbearing actually corroborates. (The + * Bedrock `InvokeModel` call carries the same SourceIdentity in CloudTrail too, + * but crossbearing's corroborated findings come from the tool-call records, not + * the model call.) + * + * No new dependency: like the `claude-cli` runtime, this shells to a CLI + * already in the agent image (`aws`) via `node:child_process`. + * + * Scope: this attributes a *cooperating* session's actions on the normal path; + * it is an attribution mechanism, not a containment control. An agent that + * controls its own tool subprocesses can step outside both bindings (drop + * `KUBECONFIG`; re-auth with the still-mounted web-identity token), so genuine + * tamper-resistance needs the platform to withhold direct RBAC from the session + * ServiceAccount and broad `sts:AssumeRole` from the session role. See the + * "Threat model" section of `docs/attribution.md`. + * + * The required IAM (a session role whose trust policy lets the tenant IRSA role + * `sts:AssumeRole` + `sts:SetSourceIdentity`) and the K8s `impersonate` RBAC + * the session ServiceAccount needs are documented in `docs/attribution.md` — + * they live on the platform side, not in fab. + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const execFileAsync = promisify(execFile); + +const ENV_OPERATOR = 'FAB_OPERATOR'; +const ENV_SESSION_ROLE = 'FAB_SESSION_ROLE_ARN'; +const ENV_SESSION_DURATION = 'FAB_SESSION_DURATION'; + +/** In-cluster ServiceAccount paths the kubelet projects into every pod. */ +const SA_TOKEN_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/token'; +const SA_CA_PATH = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'; + +const DEFAULT_DURATION_SECONDS = 3600; +const MIN_DURATION_SECONDS = 900; +// STS's absolute ceiling for assume-role. Note: in the shipped path the caller +// is the pod's own IRSA-assumed role, so this is STS *role chaining* — AWS caps +// a chained session at 1 hour regardless of the target role's +// MaxSessionDuration, and STS rejects any --duration-seconds above 3600. A value +// in (3600, 43200] therefore fails closed at assume-role time. See +// docs/attribution.md ("Duration and the role-chaining cap"). +const MAX_DURATION_SECONDS = 43200; + +/** The resolved human a session acts on behalf of, and how long for. */ +export interface SessionIdentity { + /** the named human this session acts on behalf of */ + operator: string; + /** the role assumed to carry the operator as STS SourceIdentity */ + roleArn: string; + /** seconds the assumed credentials live; STS rejects a value above the role's + * MaxSessionDuration (or 3600 under role chaining) server-side. */ + durationSeconds: number; +} + +/** A minimal `aws` runner — injectable so callers can test without the CLI. */ +export type CliRunner = (file: string, args: string[]) => Promise<{ stdout: string }>; + +const defaultRunner: CliRunner = (file, args) => execFileAsync(file, args, { timeout: 20_000 }); + +/** + * Resolve the per-session operator from the environment, or `null` when + * attribution is not configured (the session then runs unattributed — the + * platform default). + * + * An operator with no session role is a misconfiguration, not a fallback: + * there is nothing to carry the human into AWS, so fail loudly rather than + * silently run as the tenant role. + */ +export function resolveSessionIdentity(env: NodeJS.ProcessEnv = process.env): SessionIdentity | null { + const operator = env[ENV_OPERATOR]?.trim(); + if (!operator) return null; + + // The operator must already satisfy STS rules so the SAME string binds both + // sides — AWS SourceIdentity and the Kubernetes impersonated user — with no + // silent per-path sanitization that would split the human across streams. + if (!isStsValid(operator)) { + throw new Error( + `${ENV_OPERATOR}="${operator}" must be 2–64 characters from [A-Za-z0-9+=,.@_-] ` + + `(an email works) so the same identity binds AWS SourceIdentity and the Kubernetes impersonated user.`, + ); + } + + const roleArn = env[ENV_SESSION_ROLE]?.trim(); + if (!roleArn) { + throw new Error( + `${ENV_OPERATOR}=${operator} is set but ${ENV_SESSION_ROLE} is not. ` + + `Attribution needs a role to assume with the operator as STS SourceIdentity; ` + + `set ${ENV_SESSION_ROLE} to that role's ARN, or unset ${ENV_OPERATOR} to run unattributed.`, + ); + } + // Validate the ARN shape up front, matching the operator's rigor — a typo'd + // role should fail here with a clear message, not after a network round-trip + // as an opaque STS error. + if (!ROLE_ARN_RE.test(roleArn)) { + throw new Error( + `${ENV_SESSION_ROLE}="${roleArn}" is not an IAM role ARN ` + `(expected arn:aws:iam:::role/).`, + ); + } + + const raw = env[ENV_SESSION_DURATION]?.trim(); + // Decimal integers only — Number() would otherwise quietly accept hex/float + // forms ("0x384", "3600.5") and parseInt would accept "3600abc". + const durationSeconds = raw ? (/^\d+$/.test(raw) ? Number(raw) : NaN) : DEFAULT_DURATION_SECONDS; + if ( + !Number.isInteger(durationSeconds) || + durationSeconds < MIN_DURATION_SECONDS || + durationSeconds > MAX_DURATION_SECONDS + ) { + throw new Error( + `${ENV_SESSION_DURATION} must be an integer ${MIN_DURATION_SECONDS}–${MAX_DURATION_SECONDS} seconds (got "${raw}").`, + ); + } + + return { operator, roleArn, durationSeconds }; +} + +/** STS SourceIdentity / RoleSessionName rules: 2–64 chars from [A-Za-z0-9+=,.@_-]. */ +const STS_IDENTITY_RE = /^[A-Za-z0-9+=,.@_-]{2,64}$/; + +/** An IAM role ARN: arn::iam::<12-digit account>:role/. */ +const ROLE_ARN_RE = /^arn:aws[a-z-]*:iam::\d{12}:role\/.+/; + +/** Whether a value already satisfies the STS SourceIdentity / RoleSessionName rules. */ +export function isStsValid(value: string): boolean { + return STS_IDENTITY_RE.test(value); +} + +/** + * Sanitize a free-form value to the STS `RoleSessionName` charset and length + * (2–64), falling back when the result is too short. Used for the internally + * generated role-session name; the operator is validated up front instead, so + * it is never silently rewritten. + */ +export function sanitizeForSts(value: string, fallback: string): string { + const cleaned = value + .replace(/[^A-Za-z0-9+=,.@_-]/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 64); + return cleaned.length >= 2 ? cleaned : fallback; +} + +interface StsCredentials { + AccessKeyId: string; + SecretAccessKey: string; + SessionToken: string; +} + +/** + * Assume the session role carrying the operator as STS SourceIdentity, using + * the pod's own (IRSA) credentials as the caller. Returns the temporary + * credentials as the `AWS_*` env vars the agent's AWS calls will read. + */ +export async function assumeWithSourceIdentity( + id: SessionIdentity, + roleSessionName: string, + run: CliRunner = defaultRunner, +): Promise> { + const { stdout } = await run('aws', [ + 'sts', + 'assume-role', + '--role-arn', + id.roleArn, + '--role-session-name', + sanitizeForSts(roleSessionName, 'fab-session'), + '--source-identity', + id.operator, // validated STS-clean by resolveSessionIdentity; used verbatim so AWS == K8s + '--duration-seconds', + String(id.durationSeconds), + '--output', + 'json', + ]); + + let creds: StsCredentials | undefined; + try { + creds = (JSON.parse(stdout) as { Credentials?: StsCredentials }).Credentials; + } catch { + throw new Error('aws sts assume-role returned unparseable output'); + } + if (!creds?.AccessKeyId || !creds.SecretAccessKey || !creds.SessionToken) { + throw new Error('aws sts assume-role returned no usable credentials'); + } + return { + AWS_ACCESS_KEY_ID: creds.AccessKeyId, + AWS_SECRET_ACCESS_KEY: creds.SecretAccessKey, + AWS_SESSION_TOKEN: creds.SessionToken, + }; +} + +/** + * Write a kubeconfig that authenticates with the pod's ServiceAccount token + * but impersonates the operator, and return its path. The session's + * ServiceAccount must hold `impersonate` RBAC on the operator (see + * `docs/attribution.md`). `operator` is JSON-encoded so it is always a safe + * YAML scalar. + */ +export function writeImpersonationKubeconfig(operator: string, dir = mkdtempSync(join(tmpdir(), 'fab-kube-'))): string { + const path = join(dir, 'config'); + const kubeconfig = [ + 'apiVersion: v1', + 'kind: Config', + 'clusters:', + ' - name: in-cluster', + ' cluster:', + ' server: https://kubernetes.default.svc', + ` certificate-authority: ${SA_CA_PATH}`, + 'users:', + ' - name: operator', + ' user:', + ` tokenFile: ${SA_TOKEN_PATH}`, + ` as: ${JSON.stringify(operator)}`, + 'contexts:', + ' - name: in-cluster', + ' context:', + ' cluster: in-cluster', + ' user: operator', + 'current-context: in-cluster', + '', + ].join('\n'); + writeFileSync(path, kubeconfig, { mode: 0o600 }); + return path; +} + +/** + * Apply the operator's identity to `env` (defaults to `process.env`) so the + * in-process agent and its tool subprocesses inherit it: assume the session + * role with SourceIdentity (AWS) and point `kubectl` at an impersonating + * kubeconfig (K8s). Returns the resolved identity, or `null` when attribution + * is not configured. + * + * Throws on a configured-but-failed setup. Callers MUST fail the session on a + * throw rather than continue: running unattributed after a human was named + * would silently strip the binding the evidence depends on. + */ +export async function applySessionIdentity( + roleSessionName: string, + env: NodeJS.ProcessEnv = process.env, + run: CliRunner = defaultRunner, +): Promise { + const id = resolveSessionIdentity(env); + if (!id) return null; + + // Compute both bindings before mutating env, so a failure in either leaves + // env untouched rather than half-attributed (AWS set, K8s not). + const creds = await assumeWithSourceIdentity(id, roleSessionName, run); + const kubeconfig = writeImpersonationKubeconfig(id.operator); + + Object.assign(env, creds); + env.KUBECONFIG = kubeconfig; + // Drop every other credential-source env var so the default provider chain + // resolves the assumed SourceIdentity creds and nothing else. The static keys + // already outrank these in the chain; deleting them makes the assumed creds + // the *only* source the default chain can resolve, regardless of pod env + // shape, on a security-critical boundary. This governs the default chain only: + // it does not (and cannot) stop an agent from explicitly re-authenticating + // with the still-mounted web-identity token — see docs/attribution.md + // ("Threat model"). + delete env.AWS_ROLE_ARN; + delete env.AWS_WEB_IDENTITY_TOKEN_FILE; + delete env.AWS_ROLE_SESSION_NAME; + delete env.AWS_CONTAINER_CREDENTIALS_FULL_URI; + delete env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI; + delete env.AWS_PROFILE; + delete env.AWS_SHARED_CREDENTIALS_FILE; + delete env.AWS_CONFIG_FILE; + return id; +} diff --git a/src/runtimes/role-session.ts b/src/runtimes/role-session.ts index 3383957..a5cdb8a 100644 --- a/src/runtimes/role-session.ts +++ b/src/runtimes/role-session.ts @@ -1,6 +1,8 @@ import type { ParsedArgs } from '../args.js'; import type { AgentEvent, TeamRole } from '../types.js'; import { SdkRuntime } from './sdk.js'; +import { TEAM } from '../team.js'; +import { applySessionIdentity } from '../attribution.js'; /** * The in-pod role-session entrypoint. @@ -79,6 +81,33 @@ export async function executeRoleSession(args: ParsedArgs): Promise { return 1; } + // Validate the role before attribution — a cheap check that avoids issuing a + // real STS assume-role (network + CloudTrail noise + a wasted SourceIdentity + // session) for a typo'd role only to fail in runRoleSession. Throws to + // preserve runRoleSession's existing unknown-role contract. + if (!TEAM.some((m) => m.role === role)) { + throw new Error(`Unknown role: "${role}"`); + } + + // Per-session human attribution. A no-op unless FAB_OPERATOR is set; when it + // is, assume a role carrying the operator as STS SourceIdentity and point + // kubectl at an impersonating kubeconfig, so this session's AWS + K8s actions + // bind to a named human. Fail closed: if attribution was requested but the + // setup fails, abort rather than run unattributed and silently lose the + // binding the evidence depends on. (Notes go to stderr; stdout is the JSONL + // event wire.) + try { + const identity = await applySessionIdentity(`fab-${role}`); + if (identity) { + process.stderr.write( + `fab role-session: acting on behalf of ${identity.operator} (STS SourceIdentity + k8s impersonation)\n`, + ); + } + } catch (err) { + process.stderr.write(`fab role-session: attribution setup failed: ${(err as Error).message}\n`); + return 1; + } + const session = await new SdkRuntime().runRoleSession(role as TeamRole, message); return streamEventsToJsonl(session.events, (line) => { process.stdout.write(line); diff --git a/src/runtimes/sdk-k8s.ts b/src/runtimes/sdk-k8s.ts index 8fe6a0d..2adb905 100644 --- a/src/runtimes/sdk-k8s.ts +++ b/src/runtimes/sdk-k8s.ts @@ -34,7 +34,16 @@ const ENV_RUNTIME_CLASS = 'FAB_K8S_RUNTIME_CLASS'; * Dispatcher env vars forwarded onto the session pod so the in-pod `sdk` * runtime infers against the same backend the dispatcher was configured for. */ -const FORWARDED_ENV = ['FAB_INFERENCE', 'AWS_REGION', 'ANTHROPIC_AWS_WORKSPACE_ID'] as const; +const FORWARDED_ENV = [ + 'FAB_INFERENCE', + 'AWS_REGION', + 'ANTHROPIC_AWS_WORKSPACE_ID', + // Per-session human attribution (src/attribution.ts) — forwarded so the + // in-pod session can assume the operator's SourceIdentity. Unset = unattributed. + 'FAB_OPERATOR', + 'FAB_SESSION_ROLE_ARN', + 'FAB_SESSION_DURATION', +] as const; /** The session pod runs the in-pod role-session entrypoint. */ const SESSION_COMMAND = ['node', 'dist/bin/fab.js', 'role-session'];