Skip to content

Commit 68df6c8

Browse files
authored
Merge branch 'main' into feat/282-dead-code-detection-gate
2 parents e02e36f + 53a13cb commit 68df6c8

22 files changed

Lines changed: 1081 additions & 28 deletions

agent/Dockerfile

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,14 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \
4646

4747
# Install Claude Code CLI (the Python SDK requires this binary)
4848
# Then update known vulnerable transitive packages where fixed versions exist.
49+
# Pinned 2.1.191 to match the CLI bundled by claude-agent-sdk 0.2.110 (see
50+
# agent/pyproject.toml) — the SDK and the on-PATH CLI must agree on the control
51+
# protocol. This version also has the awsCredentialExport behavior #215 needs:
52+
# returned creds are cached until 5 min before the JSON's `Expiration`, so an
53+
# 8 h task re-assumes the 1 h-capped SessionRole before expiry. Older builds
54+
# only refreshed hourly on a timer, racing the role-chaining cap.
4955
RUN npm install -g npm@latest && \
50-
npm install -g @anthropic-ai/claude-code@2.1.142 && \
56+
npm install -g @anthropic-ai/claude-code@2.1.191 && \
5157
CLAUDE_NPM_ROOT="$(npm root -g)/@anthropic-ai/claude-code" && \
5258
npm --prefix "${CLAUDE_NPM_ROOT}" update tar minimatch glob cross-spawn picomatch
5359

@@ -81,6 +87,13 @@ COPY contracts/ /app/contracts/
8187
# ``WorkflowValidationError: workflow '...' not found at /app/workflows/...``.
8288
COPY agent/workflows/ /app/workflows/
8389
COPY agent/prepare-commit-msg.sh /app/
90+
# Claude Code managed settings (#215). The highest-precedence settings layer —
91+
# loaded regardless of setting_sources and unoverridable by the untrusted cloned
92+
# repo's project .claude/settings.json. Carries awsCredentialExport so Bedrock
93+
# calls use session-tagged, refreshable credentials for cost attribution.
94+
# Placing awsCredentialExport (an arbitrary command) anywhere the target repo
95+
# can influence would be RCE with the compute role, so it lives ONLY here.
96+
COPY agent/managed-settings.json /etc/claude-code/managed-settings.json
8497

8598
# Create non-root user (Claude Code CLI refuses bypassPermissions as root)
8699
RUN useradd -m -s /bin/bash agent && \

agent/managed-settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"awsCredentialExport": "/app/.venv/bin/python /app/src/bedrock_creds_helper.py"
3+
}

agent/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ dependencies = [
1616
# would degrade gracefully — but for now we keep the dep to
1717
# preserve the clean code path.
1818
"bedrock-agentcore==1.9.1", #https://pypi.org/project/bedrock-agentcore/
19-
"claude-agent-sdk==0.2.82", #https://github.com/anthropics/claude-agent-sdk-python/releases/tag/v0.2.82
19+
"claude-agent-sdk==0.2.110", #https://github.com/anthropics/claude-agent-sdk-python/releases/tag/v0.2.110 (bundles claude CLI 2.1.191; kept in lockstep with the npm CLI pin in the Dockerfile, #215)
2020
"requests==2.34.2", #https://pypi.org/project/requests/
2121
"fastapi==0.136.1", #https://pypi.org/project/fastapi/
2222
"uvicorn==0.47.0", #https://pypi.org/project/uvicorn/

agent/src/aws_session.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,19 @@ class SessionScopingError(RuntimeError):
7878
"""
7979

8080

81+
def build_session_tags(user_id: str, repo: str, task_id: str) -> list[dict[str, str]]:
82+
"""Build the AssumeRole ``Tags`` list from tenant identifiers.
83+
84+
Only non-empty values are included. Values are truncated to the IAM limit
85+
so an over-long repo slug can never make ``AssumeRole`` fail. Shared by the
86+
in-process tenant-data session (:func:`_session_tags`) and the out-of-process
87+
Bedrock credential helper (``bedrock_creds_helper.py``) so both mint the
88+
same ``{user_id, repo, task_id}`` tags from one definition.
89+
"""
90+
pairs = (("user_id", user_id), ("repo", repo), ("task_id", task_id))
91+
return [{"Key": key, "Value": value[:_MAX_TAG_VALUE_LEN]} for key, value in pairs if value]
92+
93+
8194
def configure_session(user_id: str, repo: str, task_id: str) -> None:
8295
"""Record session-tag values in private module state for later use.
8396
@@ -115,6 +128,11 @@ def _session_tags() -> list[dict[str, str]]:
115128
return [{"Key": key, "Value": value[:_MAX_TAG_VALUE_LEN]} for key, value in _tags.items()]
116129

117130

131+
# Public alias of the IAM tag-value length cap, for the Bedrock credential
132+
# helper which builds tags from CLI args rather than module state.
133+
MAX_TAG_VALUE_LEN = _MAX_TAG_VALUE_LEN
134+
135+
118136
def _build_scoped_session(role_arn: str) -> Any:
119137
"""Build a boto3 Session backed by refreshable assumed-role credentials.
120138

agent/src/bedrock_creds_helper.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
#!/usr/bin/env python3
2+
"""Credential helper for Claude Code's Bedrock calls (#215, cost attribution).
3+
4+
Claude Code (``CLAUDE_CODE_USE_BEDROCK=1``) makes every ``InvokeModel`` call —
5+
not the agent's boto3 — so the per-task tenant-data SessionRole in
6+
``aws_session.py`` cannot tag those calls. Instead Claude Code's
7+
``awsCredentialExport`` setting (in the image's managed-settings layer) runs
8+
this script, captures its JSON stdout, and signs Bedrock requests with the
9+
returned credentials. With a real ``Expiration`` it re-runs ~5 min before
10+
expiry, so an 8 h task survives the 1 h role-chaining cap.
11+
12+
Goal: assume the per-task SessionRole with ``{user_id, repo, task_id}`` STS
13+
session tags so Bedrock spend is attributable per user/repo in AWS Cost
14+
Explorer / CUR 2.0 (``iamPrincipal/*`` dimensions, after the operator activates
15+
the cost-allocation tags). The same role already carries the tenant-data grants;
16+
Track-1 only adds ``bedrock:InvokeModel*`` to it (see ``agent-session-role.ts``).
17+
18+
**Fails OPEN.** Bedrock attribution is a billing/observability control, not a
19+
tenant-isolation one (contrast ``aws_session.py``, which fails closed). If the
20+
attribution config is absent or the assume-role fails, this helper emits the
21+
**ambient** compute-role credentials so Bedrock keeps working untagged — losing
22+
chargeback granularity is not a security incident, and the compute role retains
23+
``InvokeModel`` precisely so this fallback works.
24+
25+
The role ARN and tag values are read from a 0600 JSON file the agent writes at
26+
startup (``write_attribution_file``), not from the environment — so the tenant
27+
identifiers are not inherited by the untrusted repo subprocesses the agent
28+
spawns, matching the discipline in ``aws_session.py``.
29+
30+
Output shape (consumed by Claude Code's awsCredentialExport):
31+
32+
{"Credentials": {"AccessKeyId": "...", "SecretAccessKey": "...",
33+
"SessionToken": "...", "Expiration": "<ISO8601>"}}
34+
"""
35+
36+
from __future__ import annotations
37+
38+
import json
39+
import os
40+
import sys
41+
from typing import Any
42+
43+
# Fixed path the agent writes (0600) and this helper reads. A fixed path is
44+
# required because the managed-settings ``awsCredentialExport`` command is
45+
# static (baked into the image) and cannot carry per-task arguments.
46+
ATTRIBUTION_FILE_ENV = "BEDROCK_ATTRIBUTION_FILE"
47+
DEFAULT_ATTRIBUTION_FILE = "/home/agent/.bedrock-attribution.json"
48+
49+
# Role chaining caps the assumed session at 1 hour; request the max the cap
50+
# allows. Claude Code refreshes ~5 min before the returned Expiration.
51+
_CHAINED_SESSION_DURATION_S = 3600
52+
53+
54+
def attribution_file_path() -> str:
55+
return os.environ.get(ATTRIBUTION_FILE_ENV, "").strip() or DEFAULT_ATTRIBUTION_FILE
56+
57+
58+
def write_attribution_file(
59+
role_arn: str, tags: list[dict[str, str]], path: str | None = None
60+
) -> str:
61+
"""Persist the SessionRole ARN + STS tags for the helper to read.
62+
63+
Written 0600 and owned by the agent user. Returns the path written. Called
64+
by the agent at startup (see ``runner._setup_agent_env``) only when a
65+
SessionRole is configured; absence is the fail-open signal.
66+
"""
67+
target = path or attribution_file_path()
68+
payload = json.dumps({"role_arn": role_arn, "tags": tags})
69+
# Create with 0600 from the start (os.open + O_CREAT honors mode, modulo
70+
# umask) so the secret-adjacent file is never briefly world-readable.
71+
fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
72+
with os.fdopen(fd, "w") as fh:
73+
fh.write(payload)
74+
return target
75+
76+
77+
def _warn(message: str) -> None:
78+
"""Emit a diagnostic to stderr.
79+
80+
This process's **stdout is the credential channel** — Claude Code parses it
81+
as the ``awsCredentialExport`` JSON result — so diagnostics MUST go to
82+
stderr or they would corrupt the credential envelope. (This is also why
83+
``shell.log``, which writes to fd 1, is unusable here.) Every fail-open path
84+
logs through here so a silent, weeks-long loss of cost attribution is
85+
instead a visible, correlatable signal — the fallback stays open, but it is
86+
never invisible.
87+
"""
88+
print(f"[bedrock-creds] {message}", file=sys.stderr)
89+
90+
91+
def _emit(creds: dict[str, str]) -> None:
92+
json.dump({"Credentials": creds}, sys.stdout)
93+
94+
95+
def _frozen_to_creds(frozen: Any, expiry_iso: str | None) -> dict[str, str]:
96+
out = {
97+
"AccessKeyId": frozen.access_key,
98+
"SecretAccessKey": frozen.secret_key,
99+
"SessionToken": frozen.token or "",
100+
}
101+
if expiry_iso:
102+
out["Expiration"] = expiry_iso
103+
return out
104+
105+
106+
def _ambient_credentials() -> dict[str, str]:
107+
"""Frozen ambient (compute-role) credentials — the fail-open fallback."""
108+
import botocore.session
109+
110+
creds = botocore.session.get_session().get_credentials()
111+
if creds is None:
112+
# No resolvable credentials at all — the deepest degradation. Emit an
113+
# empty object; Claude Code then falls back to its own default-chain
114+
# resolution. Surface it: if that fallback also fails, this stderr line
115+
# is the only breadcrumb.
116+
_warn(
117+
"no resolvable AWS credentials; emitting empty envelope, "
118+
"Claude Code will use its default chain"
119+
)
120+
return {}
121+
return _frozen_to_creds(creds.get_frozen_credentials(), None)
122+
123+
124+
def resolve_credentials() -> dict[str, str]:
125+
"""Return tagged assumed-role creds, or ambient creds on any failure."""
126+
path = attribution_file_path()
127+
try:
128+
with open(path) as fh:
129+
cfg = json.load(fh)
130+
role_arn = cfg["role_arn"]
131+
tags = cfg.get("tags", [])
132+
except FileNotFoundError:
133+
# Attribution not configured (local/dev, or pre-provisioning). Expected
134+
# and benign — debug-level signal only.
135+
_warn("attribution file absent; not configured — using ambient creds")
136+
return _ambient_credentials()
137+
except (OSError, ValueError, KeyError) as exc:
138+
# File present but unreadable/malformed/schema-drifted. This is NOT the
139+
# benign "not configured" case — it points at a write_attribution_file
140+
# bug or a partial write, so it warrants a louder signal.
141+
_warn(
142+
f"attribution file present but unreadable ({type(exc).__name__}: {exc}); "
143+
"using ambient creds"
144+
)
145+
return _ambient_credentials()
146+
147+
try:
148+
import boto3
149+
from botocore.exceptions import BotoCoreError, ClientError
150+
except ImportError as exc:
151+
# boto3 missing/broken in the image is a packaging defect, not the
152+
# expected assume-role failure — name it explicitly so it can't hide.
153+
_warn(f"boto3 unavailable ({exc}); using ambient creds — fix the image")
154+
return _ambient_credentials()
155+
156+
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
157+
task_id = next((t["Value"] for t in tags if t.get("Key") == "task_id"), "")
158+
session_name = f"abca-bedrock-{task_id}"[:64] or "abca-bedrock"
159+
try:
160+
resp = boto3.client("sts", region_name=region).assume_role(
161+
RoleArn=role_arn,
162+
RoleSessionName=session_name,
163+
DurationSeconds=_CHAINED_SESSION_DURATION_S,
164+
Tags=tags,
165+
)
166+
c = resp["Credentials"]
167+
return {
168+
"AccessKeyId": c["AccessKeyId"],
169+
"SecretAccessKey": c["SecretAccessKey"],
170+
"SessionToken": c["SessionToken"],
171+
"Expiration": c["Expiration"].isoformat(),
172+
}
173+
except (ClientError, BotoCoreError) as exc:
174+
# Expected assume failure: role not yet provisioned, AccessDenied,
175+
# transient STS error. Fail open so Bedrock keeps working on the
176+
# compute role; spend for this task is untagged.
177+
_warn(
178+
f"assume_role failed ({type(exc).__name__}: {exc}); using ambient creds "
179+
"— Bedrock spend will be UNTAGGED"
180+
)
181+
return _ambient_credentials()
182+
except Exception as exc:
183+
# Anything else (unexpected STS response shape, a logic bug here) is NOT
184+
# the expected fallback. Still fail open — this is a billing control, not
185+
# isolation — but flag it distinctly so it isn't mistaken for AccessDenied.
186+
_warn(
187+
f"UNEXPECTED error minting tagged creds ({type(exc).__name__}: {exc}); "
188+
"using ambient creds"
189+
)
190+
return _ambient_credentials()
191+
192+
193+
def main() -> int:
194+
_emit(resolve_credentials())
195+
return 0
196+
197+
198+
if __name__ == "__main__":
199+
sys.exit(main())

agent/src/runner.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,58 @@ def _parse_token_usage(raw_usage: Any) -> TokenUsage:
5959
return TokenUsage(**values)
6060

6161

62+
def _setup_bedrock_cost_attribution(config: TaskConfig) -> None:
63+
"""Wire Bedrock cost attribution for the Claude Code subprocess (#215).
64+
65+
Claude Code makes the ``InvokeModel`` calls, so attribution is configured
66+
through *its* credential + header channels, not the agent's boto3:
67+
68+
1. **Per-user/repo chargeback (CUR 2.0 / Cost Explorer).** Write the
69+
SessionRole ARN + ``{user_id, repo, task_id}`` STS tags to a 0600 file
70+
that ``bedrock_creds_helper.py`` reads. Claude Code's managed-settings
71+
``awsCredentialExport`` runs that helper and signs Bedrock requests with
72+
the tagged assumed-role credentials. Skipped when ``AGENT_SESSION_ROLE_ARN``
73+
is unset (local/dev) — the helper then fails open to ambient creds.
74+
75+
2. **Per-call forensics (model-invocation logs).** Set
76+
``X-Amzn-Bedrock-Request-Metadata`` via ``ANTHROPIC_CUSTOM_HEADERS`` on the
77+
process env. One container = one task = one Claude Code session, so a
78+
static-per-process header is effectively per-task. Set via the process
79+
env (not project settings) so the untrusted cloned repo cannot alter it.
80+
"""
81+
import json
82+
83+
from aws_session import MAX_TAG_VALUE_LEN, build_session_tags
84+
85+
role_arn = os.environ.get("AGENT_SESSION_ROLE_ARN", "").strip()
86+
tags = build_session_tags(config.user_id, config.repo_url, config.task_id)
87+
if role_arn and tags:
88+
try:
89+
from bedrock_creds_helper import write_attribution_file
90+
91+
write_attribution_file(role_arn, tags)
92+
except OSError as exc:
93+
# Fail open: attribution is observability, not isolation. Bedrock
94+
# still works on the compute role; we just lose tagged chargeback.
95+
log("WARN", f"Bedrock attribution file not written ({exc}); spend will be untagged")
96+
97+
# Per-request metadata mirrors the STS tag values. Bedrock limits keys/values
98+
# to 256 chars and records them under ``requestMetadata`` in invocation logs.
99+
#
100+
# Unlike the tenant-data tags (kept out of os.environ so untrusted repo
101+
# subprocesses don't inherit them), this header MUST go on os.environ —
102+
# Claude Code reads ANTHROPIC_CUSTOM_HEADERS from the process env. The
103+
# exposure is acceptable: the values are the task's OWN {user_id, repo,
104+
# task_id} (self-referential, non-secret), so a spawned subprocess learns
105+
# only who it is already running for. json.dumps escapes newlines/quotes, so
106+
# a crafted repo slug cannot inject an extra (newline-separated) header.
107+
metadata = {t["Key"]: t["Value"][:MAX_TAG_VALUE_LEN] for t in tags}
108+
if metadata:
109+
os.environ["ANTHROPIC_CUSTOM_HEADERS"] = (
110+
f"X-Amzn-Bedrock-Request-Metadata: {json.dumps(metadata, separators=(',', ':'))}"
111+
)
112+
113+
62114
def _setup_agent_env(config: TaskConfig) -> tuple[str | None, str | None]:
63115
"""Configure process environment for the Claude Code CLI subprocess.
64116
@@ -72,6 +124,8 @@ def _setup_agent_env(config: TaskConfig) -> tuple[str | None, str | None]:
72124
os.environ["ANTHROPIC_MODEL"] = config.anthropic_model
73125
os.environ["GITHUB_TOKEN"] = config.github_token
74126
os.environ["GH_TOKEN"] = config.github_token
127+
128+
_setup_bedrock_cost_attribution(config)
75129
# DO NOT set ANTHROPIC_LOG — any logging level causes the CLI to write to
76130
# stderr, which fills the OS pipe buffer (64 KB) and deadlocks the
77131
# single-threaded Node.js CLI process (blocked stderr write prevents stdout

0 commit comments

Comments
 (0)