Skip to content

Commit 88fd80f

Browse files
author
bgagent
committed
refactor(agent): keep session tags in module state, not os.environ (#209)
Address type-design review: configure_session previously wrote tenant identifiers (user_id/repo/task_id) to os.environ, which the untrusted repo subprocesses the agent spawns would inherit. Store them in a private module dict instead, removing the information-exposure surface for a control whose whole purpose is tenant isolation. Add a test asserting tags never reach os.environ. 811 agent + 1798 cdk + 286 cli tests pass; full build green. Refs #209
1 parent ca09d6b commit 88fd80f

2 files changed

Lines changed: 41 additions & 42 deletions

File tree

agent/src/aws_session.py

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,6 @@
5151
# compute environment (AgentCore runtime env / ECS container overrides).
5252
SESSION_ROLE_ARN_ENV = "AGENT_SESSION_ROLE_ARN"
5353

54-
# Env vars carrying the session-tag values. The agent exports these from its
55-
# resolved TaskConfig at startup (see ``configure_session``) so that lazily
56-
# created clients downstream can pick them up without threading config through
57-
# every call site.
58-
USER_ID_ENV = "AGENT_SESSION_USER_ID"
59-
REPO_ENV = "AGENT_SESSION_REPO"
60-
TASK_ID_ENV = "AGENT_SESSION_TASK_ID"
61-
6254
# Role chaining caps the assumed session at 1 hour. Request the maximum the
6355
# cap allows; botocore refreshes well before this elapses.
6456
_CHAINED_SESSION_DURATION_S = 3600
@@ -70,6 +62,12 @@
7062
_session: Any = None # cached boto3.Session (scoped or plain)
7163
_scoped: bool | None = None # None until first resolution; True if tag-scoped
7264

65+
# Session-tag values, set once at startup by ``configure_session`` from the
66+
# resolved TaskConfig. Kept in private module state — NOT os.environ — so the
67+
# tenant identifiers are not inherited by the untrusted repo subprocesses the
68+
# agent spawns (build/test/tooling). Read by ``_session_tags`` at assume time.
69+
_tags: dict[str, str] = {}
70+
7371

7472
class SessionScopingError(RuntimeError):
7573
"""Per-session IAM scoping was requested but could not be established.
@@ -81,41 +79,40 @@ class SessionScopingError(RuntimeError):
8179

8280

8381
def configure_session(user_id: str, repo: str, task_id: str) -> None:
84-
"""Export session-tag values to the environment for later client creation.
82+
"""Record session-tag values in private module state for later use.
8583
8684
Called once at agent startup from the resolved ``TaskConfig``. Idempotent;
8785
safe to call before any tenant-data client is created. Does not itself
8886
assume the role — assumption is deferred until the first client is built so
89-
that a missing SessionRole never delays startup.
87+
that a missing SessionRole never delays startup. Values are stored in a
88+
module global (not ``os.environ``) so tenant identifiers do not leak into
89+
spawned subprocesses.
9090
"""
91-
if user_id:
92-
os.environ[USER_ID_ENV] = user_id
93-
if repo:
94-
os.environ[REPO_ENV] = repo
95-
if task_id:
96-
os.environ[TASK_ID_ENV] = task_id
91+
global _tags
92+
_tags = {
93+
key: value
94+
for key, value in (("user_id", user_id), ("repo", repo), ("task_id", task_id))
95+
if value
96+
}
9797

9898

9999
def reset_session_cache() -> None:
100-
"""Drop the cached session. For tests that toggle env between cases."""
101-
global _session, _scoped
100+
"""Drop the cached session and tags. For tests that toggle config."""
101+
global _session, _scoped, _tags
102102
with _lock:
103103
_session = None
104104
_scoped = None
105+
_tags = {}
105106

106107

107108
def _session_tags() -> list[dict[str, str]]:
108-
"""Build the AssumeRole ``Tags`` list from the configured env values.
109+
"""Build the AssumeRole ``Tags`` list from the configured tag values.
109110
110-
Only non-empty values are included. Values are truncated to the IAM limit
111-
so an over-long repo slug can never make ``AssumeRole`` fail closed.
111+
Only non-empty values are included (filtered at ``configure_session``).
112+
Values are truncated to the IAM limit so an over-long repo slug can never
113+
make ``AssumeRole`` fail closed.
112114
"""
113-
pairs = (
114-
("user_id", os.environ.get(USER_ID_ENV, "")),
115-
("repo", os.environ.get(REPO_ENV, "")),
116-
("task_id", os.environ.get(TASK_ID_ENV, "")),
117-
)
118-
return [{"Key": key, "Value": value[:_MAX_TAG_VALUE_LEN]} for key, value in pairs if value]
115+
return [{"Key": key, "Value": value[:_MAX_TAG_VALUE_LEN]} for key, value in _tags.items()]
119116

120117

121118
def _build_scoped_session(role_arn: str) -> Any:
@@ -132,7 +129,7 @@ def _build_scoped_session(role_arn: str) -> Any:
132129
from botocore.session import get_session as get_botocore_session
133130

134131
region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
135-
task_id = os.environ.get(TASK_ID_ENV, "")
132+
task_id = _tags.get("task_id", "")
136133
# Role session name must be <=64 chars and match [\w+=,.@-]. task_id is a
137134
# short slug (a ULID, ~26 chars, in the API path; a 12-char hex fallback
138135
# when the agent generates its own) — well under 64. The ``abca-`` prefix
@@ -204,7 +201,7 @@ def get_session() -> Any:
204201
f"scoped session could not be built ({type(exc).__name__}: {exc}). "
205202
"Failing closed — refusing to run on unscoped ambient credentials, "
206203
"which would disable tenant isolation.",
207-
task_id=os.environ.get(TASK_ID_ENV) or None,
204+
task_id=_tags.get("task_id") or None,
208205
)
209206
raise SessionScopingError(
210207
"per-session IAM scoping requested via "

agent/tests/test_aws_session.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,7 @@
1313

1414
import aws_session
1515
from aws_session import (
16-
REPO_ENV,
1716
SESSION_ROLE_ARN_ENV,
18-
TASK_ID_ENV,
19-
USER_ID_ENV,
2017
configure_session,
2118
get_session,
2219
is_scoped,
@@ -26,9 +23,8 @@
2623

2724
@pytest.fixture(autouse=True)
2825
def _reset(monkeypatch):
29-
"""Clear cache + session-tag env between tests."""
30-
for var in (SESSION_ROLE_ARN_ENV, USER_ID_ENV, REPO_ENV, TASK_ID_ENV):
31-
monkeypatch.delenv(var, raising=False)
26+
"""Clear cache + session tags between tests."""
27+
monkeypatch.delenv(SESSION_ROLE_ARN_ENV, raising=False)
3228
reset_session_cache()
3329
yield
3430
reset_session_cache()
@@ -40,18 +36,24 @@ def _reset(monkeypatch):
4036

4137

4238
class TestConfigureSession:
43-
def test_exports_nonempty_tag_values(self, monkeypatch):
39+
def test_records_nonempty_tag_values_in_private_state(self, monkeypatch):
4440
monkeypatch.setenv("AWS_REGION", "us-east-1")
4541
configure_session(user_id="u-1", repo="owner/repo", task_id="t-abc")
46-
assert aws_session.os.environ[USER_ID_ENV] == "u-1"
47-
assert aws_session.os.environ[REPO_ENV] == "owner/repo"
48-
assert aws_session.os.environ[TASK_ID_ENV] == "t-abc"
42+
assert aws_session._tags == {
43+
"user_id": "u-1",
44+
"repo": "owner/repo",
45+
"task_id": "t-abc",
46+
}
4947

5048
def test_skips_empty_values(self, monkeypatch):
5149
configure_session(user_id="", repo="", task_id="t-only")
52-
assert USER_ID_ENV not in aws_session.os.environ
53-
assert REPO_ENV not in aws_session.os.environ
54-
assert aws_session.os.environ[TASK_ID_ENV] == "t-only"
50+
assert aws_session._tags == {"task_id": "t-only"}
51+
52+
def test_does_not_leak_tags_into_os_environ(self, monkeypatch):
53+
"""Tenant identifiers must not land in os.environ (subprocesses inherit it)."""
54+
configure_session(user_id="u-1", repo="owner/repo", task_id="t-abc")
55+
assert "AGENT_SESSION_USER_ID" not in aws_session.os.environ
56+
assert "u-1" not in aws_session.os.environ.values()
5557

5658

5759
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)