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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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::<account-id>:role/<session-role>
# FAB_SESSION_DURATION=3600 # seconds, 900–3600 under role chaining

# ── Service credentials ──────────────────────────────────────────────
# See .env.vault and docs/VAULT_SETUP.md
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<phase>/<area>.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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<human>` 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
Expand Down
225 changes: 225 additions & 0 deletions __tests__/attribution.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
30 changes: 30 additions & 0 deletions __tests__/role-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = {}): ParsedArgs {
return { command: 'role-session', sub: '', positional: [], flags: {}, ...overrides };
Expand Down Expand Up @@ -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);
});
});
20 changes: 20 additions & 0 deletions __tests__/sdk-k8s.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading