Skip to content

Commit 85e141d

Browse files
authored
Merge branch 'main' into fix/502-ecs-payload-s3-pointer
2 parents 331751e + 092361e commit 85e141d

56 files changed

Lines changed: 2892 additions & 405 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitleaks.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ stopwords = ["wat-opaque-123"]
1919
description = "Test fixture signing secret in Slack verification unit test (not a real credential)."
2020
stopwords = ["test-signing-secret-abc123"]
2121

22+
[[allowlists]]
23+
# Test idempotency-key fixture in orchestration-release.test.ts (not a real
24+
# credential). Suppressed by stopword rather than a .gitleaksignore commit-SHA
25+
# fingerprint: the finding lives in history and SHA fingerprints break whenever
26+
# history is rewritten (rebases / dep-bump merges), which is exactly how #530's
27+
# baseline regressed. A stopword is SHA-independent. See #537 (regression of #530).
28+
description = "Test idempotency-key fixture 'orch_abc_SUB-1' (not a real credential)."
29+
stopwords = ["orch_abc_SUB-1"]
30+
2231
# Catch bare 12-digit AWS account IDs. The default ruleset does not flag these,
2332
# which is how a real account ID reached a committed comment in the #236 integ
2433
# work. RE2 (Go) has no lookarounds, so the non-digit neighbours are captured in

.gitleaksignore

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
0dca2171f244f693ac7c649ecf376d1ce6af2b38:docs/backlog/CLI_COGNITO_NEW_PASSWORD_CHALLENGE.md:aws-account-id:5
77
0dca2171f244f693ac7c649ecf376d1ce6af2b38:docs/diagrams/phase3-cedar-hitl.drawio:aws-account-id:28
88

9-
# False positive: generic-api-key on a test idempotency-key fixture
10-
# ('orch_abc_SUB-1', not a real credential) in a file that no longer exists at
11-
# HEAD — it was removed when the #76 bounded-parallel perf commit was reverted
12-
# (e8bee5e). The finding lives only in history at f98f47a, so it can't be
13-
# scrubbed without rewriting shared `main`; fingerprint it instead. See #530.
14-
f98f47abc6f7820d7e5ce0375cf22551cb664b37:cdk/test/handlers/shared/orchestration-release.test.ts:generic-api-key:125
9+
# NOTE: the orch_abc_SUB-1 test-fixture false positive (formerly baselined here
10+
# by commit-SHA fingerprint, #530) is now suppressed SHA-independently via a
11+
# stopword allowlist in .gitleaks.toml — a commit-SHA fingerprint here broke when
12+
# history was rewritten. See #537.

agent/src/hooks.py

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,102 @@ def _tool_input_preview(tool_input: Any, max_len: int = TOOL_INPUT_PREVIEW_MAX)
9999
return _truncate(_strip_ansi(rendered), max_len)
100100

101101

102+
# ---------------------------------------------------------------------------
103+
# Egress-denial detection (#251, Phase 1)
104+
# ---------------------------------------------------------------------------
105+
#
106+
# Best-effort, heuristic scan of a tool's output for the signatures a blocked
107+
# outbound connection leaves behind — a non-allowlisted host hitting the DNS
108+
# Firewall, a refused connection, or a name-resolution failure. When one
109+
# matches we emit an ``egress_denied`` blocker naming the host to allowlist.
110+
# This is intentionally a string-signature scan (not a network probe): the
111+
# model's Bash tool calls (``npm install``, ``git clone``, ``curl``) surface
112+
# their stderr here, and those tools use stable, well-known phrasings. Patterns
113+
# that capture a host use a named ``host`` group; detection-only patterns omit
114+
# it (host stays None → the event still fires, just without ``resource``).
115+
_EGRESS_DENIAL_PATTERNS: tuple[re.Pattern[str], ...] = (
116+
# curl / libcurl, git-over-https
117+
re.compile(r"[Cc]ould not resolve host:?\s+(?P<host>[A-Za-z0-9._-]+)"),
118+
re.compile(r"[Ff]ailed to connect to\s+(?P<host>[A-Za-z0-9._-]+)"),
119+
# Node (npm/yarn), python (requests/urllib), getaddrinfo
120+
re.compile(r"getaddrinfo (?:ENOTFOUND|EAI_AGAIN)\s+(?P<host>[A-Za-z0-9._-]+)"),
121+
# requests/urllib3: the real host appears in ``HTTPSConnectionPool(host='x')``
122+
# or after ``Failed to resolve 'x'``. Match those anchors specifically —
123+
# a bare ``NameResolutionError.*?<fqdn>`` lazy-match would instead capture
124+
# the ``urllib3.connection.HTTPSConnection`` class path that precedes the host.
125+
re.compile(r"HTTPS?ConnectionPool\(host='(?P<host>[A-Za-z0-9._-]+)'"),
126+
re.compile(r"Failed to resolve '(?P<host>[A-Za-z0-9._-]+)'"),
127+
re.compile(
128+
r"nodename nor servname provided|Temporary failure in name resolution"
129+
r"|Name or service not known|Connection refused|Network is unreachable"
130+
r"|NameResolutionError|Failed to establish a new connection",
131+
),
132+
)
133+
134+
135+
# #251 carry-path: the most recent agent-detected blocker, as a canonical
136+
# ``BLOCKED[<kind>]: …`` reason string (see ``format_blocker_reason``). Blockers
137+
# are detected mid-turn in the hooks (egress in PostToolUse, fail-closed in
138+
# PreToolUse) but the terminal ``error_message`` is assembled later in the
139+
# pipeline. This latch bridges the two: the pipeline promotes it into
140+
# ``TaskResult.error`` ONLY when the task errored without a more specific
141+
# reason, so the CDK classifier surfaces a precise remedy. Process-lifetime
142+
# (== one task) like ``_INJECTED_NUDGES``; last-writer-wins is fine — the most
143+
# recent blocker is the most relevant to a terminal failure.
144+
_LAST_BLOCKER_REASON: str | None = None
145+
146+
147+
def _record_blocker_reason(kind: str, detail: str, resource: str | None = None) -> None:
148+
"""Latch the canonical reason for the most recent agent-detected blocker."""
149+
global _LAST_BLOCKER_REASON
150+
from progress_writer import format_blocker_reason
151+
152+
_LAST_BLOCKER_REASON = format_blocker_reason(kind, detail, resource)
153+
154+
155+
def last_blocker_reason() -> str | None:
156+
"""Return the latched canonical blocker reason for terminal promotion (#251)."""
157+
return _LAST_BLOCKER_REASON
158+
159+
160+
def reset_blocker_reason() -> None:
161+
"""Clear the in-process blocker latch.
162+
163+
Called at the start of each ``run_task`` (the latch is a process-scalar,
164+
not task_id-keyed, so it must be reset per task to avoid leaking a prior
165+
task's blocker) and by test fixtures for isolation.
166+
"""
167+
global _LAST_BLOCKER_REASON
168+
_LAST_BLOCKER_REASON = None
169+
170+
171+
# Back-compat alias for test fixtures that import the old name.
172+
_reset_blocker_reason_for_tests = reset_blocker_reason
173+
174+
175+
def detect_egress_denial(text: str) -> tuple[bool, str | None]:
176+
"""Scan tool output for an egress-denial signature (#251).
177+
178+
Returns ``(detected, host_or_None)``: ``(True, host)`` on the first matching
179+
signature (``host`` populated when a pattern captured one so the emitted
180+
``egress_denied`` event can name the exact domain to allowlist; ``None`` for
181+
detection-only signatures), or ``(False, None)`` when nothing matches.
182+
183+
Best-effort and deliberately conservative: it only fires on the
184+
well-known phrasings the common CLIs (curl/git/npm/pip) emit, so a task
185+
log that merely *mentions* "connection refused" in prose could produce a
186+
false positive — acceptable for an observability signal that never blocks.
187+
"""
188+
if not text:
189+
return False, None
190+
for pattern in _EGRESS_DENIAL_PATTERNS:
191+
match = pattern.search(text)
192+
if match:
193+
host = match.groupdict().get("host") if match.groupdict() else None
194+
return True, host
195+
return False, None
196+
197+
102198
def _deny_response(reason: str) -> dict:
103199
"""Build a PreToolUse DENY response with a sanitized reason.
104200
@@ -214,6 +310,30 @@ async def pre_tool_use_hook(
214310
"write_policy_decision_cached",
215311
**decision.cache_hit_metadata,
216312
)
313+
# #251 (decision E): a fail-closed deny means the Cedar engine errored
314+
# or was unavailable — an ENVIRONMENTAL fault (misconfiguration), NOT
315+
# an intentional hard-deny. Emit a ``policy_fail_closed`` blocker event
316+
# so it renders distinctly and carries a remediation hint. We branch on
317+
# the structured ``decision.fail_closed`` flag, never a reason-string
318+
# prefix. Intentional hard-denies / cache-denies (fail_closed=False)
319+
# never emit. Emitted strictly BEFORE the deny; the deny itself is
320+
# unchanged, so this adds observability without altering the outcome.
321+
if getattr(decision, "fail_closed", False):
322+
_record_blocker_reason("policy_fail_closed", decision.reason)
323+
if progress is not None:
324+
_try_progress(
325+
progress,
326+
"write_agent_blocked",
327+
kind="policy_fail_closed",
328+
detail=decision.reason,
329+
remediation_hint=(
330+
"The Cedar policy engine errored or was unavailable "
331+
"(fail-closed). Check the agent policy-engine logs and the "
332+
"policy bundle; this is a misconfiguration, not an "
333+
"intentional deny."
334+
),
335+
retryable=False,
336+
)
217337
log("POLICY", f"DENIED: {tool_name}{decision.reason}")
218338
return _deny_response(decision.reason)
219339

@@ -918,13 +1038,19 @@ async def post_tool_use_hook(
9181038
hook_context: Any,
9191039
*,
9201040
trajectory: _TrajectoryWriter | None = None,
1041+
progress: Any = None,
9211042
) -> dict:
9221043
"""PostToolUse hook: screen tool output for secrets/PII.
9231044
9241045
Returns a dict with hookSpecificOutput. When sensitive content is
9251046
detected the response includes ``updatedMCPToolOutput`` containing the
9261047
redacted version (steered enforcement — content is sanitized, not
9271048
blocked).
1049+
1050+
``progress`` is optional (preserves the Phase 1 test call shape). When
1051+
present, an egress-denial signature in the tool output emits an
1052+
``egress_denied`` blocker event (#251) — best-effort observability,
1053+
never alters the screening decision.
9281054
"""
9291055
_PASS_THROUGH: dict = {"hookSpecificOutput": {"hookEventName": "PostToolUse"}}
9301056
_FAIL_CLOSED: dict = {
@@ -950,6 +1076,43 @@ async def post_tool_use_hook(
9501076
if not isinstance(tool_response, str):
9511077
tool_response = str(tool_response)
9521078

1079+
# #251: best-effort egress-denial detection. A blocked outbound connection
1080+
# (non-allowlisted host hitting the DNS Firewall, refused connection, name
1081+
# resolution failure) surfaces in the tool's stderr here. Emit an
1082+
# ``egress_denied`` blocker naming the host to allowlist. Observability
1083+
# only — never changes the pass-through / redaction decision below.
1084+
egress_detected, host = detect_egress_denial(tool_response)
1085+
if egress_detected:
1086+
detail = (
1087+
f"A connection to {host!r} was blocked"
1088+
if host
1089+
else "An outbound connection was blocked"
1090+
) + f" during {tool_name}."
1091+
# Latch for terminal promotion ONLY when a host was captured. The latch
1092+
# feeds the authoritative TaskResult.error carry-path, so a host-less,
1093+
# detection-only signature ("Connection refused" / "Network is
1094+
# unreachable") — which commonly fires for an internal localhost race,
1095+
# not an egress denial — must not masquerade as the terminal cause. The
1096+
# observability event below still fires unconditionally (it never blocks
1097+
# and is clearly a heuristic signal in the live stream).
1098+
if host:
1099+
_record_blocker_reason("egress_denied", detail, host)
1100+
if progress is not None:
1101+
_try_progress(
1102+
progress,
1103+
"write_agent_blocked",
1104+
kind="egress_denied",
1105+
detail=detail,
1106+
remediation_hint=(
1107+
f"Allowlist {host!r} in the DNS Firewall rule group"
1108+
if host
1109+
else "Allowlist the required host in the DNS Firewall rule group"
1110+
)
1111+
+ " if it is a legitimate dependency. The agent never widens egress itself.",
1112+
retryable=False,
1113+
resource=host,
1114+
)
1115+
9531116
try:
9541117
result = scan_tool_output(tool_response)
9551118
except Exception as exc:
@@ -1404,7 +1567,9 @@ async def _post(
14041567
hook_input: HookInput, tool_use_id: str | None, ctx: HookContext
14051568
) -> HookJSONOutput:
14061569
try:
1407-
result = await post_tool_use_hook(hook_input, tool_use_id, ctx, trajectory=trajectory)
1570+
result = await post_tool_use_hook(
1571+
hook_input, tool_use_id, ctx, trajectory=trajectory, progress=progress
1572+
)
14081573
return SyncHookJSONOutput(**result)
14091574
except Exception as exc:
14101575
log("ERROR", f"PostToolUse wrapper crashed: {type(exc).__name__}: {exc}")

agent/src/observability.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,24 +64,54 @@ def current_otel_trace_id() -> str | None:
6464
bundle) so operators can join the task to its CloudWatch/X-Ray trace. Returns
6565
``None`` when there is no recording span (e.g. tracing disabled locally) or
6666
the context is invalid, so callers can treat it as a graceful-missing field.
67+
68+
Never raises: a broken/misconfigured tracer degrades to ``None`` rather than
69+
propagating. Callers read this inside DDB-write try-blocks (progress_writer),
70+
where a raised trace error would otherwise be misclassified as a DDB failure
71+
and trip the shared progress circuit breaker.
6772
"""
68-
span = trace.get_current_span()
69-
ctx = span.get_span_context()
70-
if not ctx.is_valid:
73+
try:
74+
span = trace.get_current_span()
75+
ctx = span.get_span_context()
76+
if not ctx.is_valid:
77+
return None
78+
# format_trace_id renders the 128-bit id as zero-padded 32-char hex — the
79+
# OTEL format, so it joins directly in CloudWatch Transaction Search. Note
80+
# the X-Ray console renders trace ids as ``1-{8hex}-{24hex}``; to look this
81+
# up there, transform to that form (the timestamp is the first 8 hex chars).
82+
return trace.format_trace_id(ctx.trace_id)
83+
except Exception:
84+
# nosemgrep: py-silent-success-masking -- trace id is a graceful-missing
85+
# correlation field; a tracer fault must not fail the caller's write path.
7186
return None
72-
# format_trace_id renders the 128-bit id as zero-padded 32-char hex — the
73-
# OTEL format, so it joins directly in CloudWatch Transaction Search. Note
74-
# the X-Ray console renders trace ids as ``1-{8hex}-{24hex}``; to look this
75-
# up there, transform to that form (the timestamp is the first 8 hex chars).
76-
return trace.format_trace_id(ctx.trace_id)
7787

7888

79-
def set_session_id(session_id: str) -> None:
80-
"""Propagate *session_id* via OTEL baggage for AgentCore session correlation.
89+
def propagate_correlation_context(
90+
session_id: str,
91+
user_id: str = "",
92+
# ``user_id`` uses ""-means-absent (Cognito sub, mirrors AgentConfig.user_id
93+
# which is never None); ``repo`` uses None-means-absent (mirrors the optional
94+
# TaskRecord.repo). Both conventions flow from upstream config types; the
95+
# ``if x:`` guards below flatten either to "don't set the baggage key".
96+
repo: str | None = None,
97+
) -> None:
98+
"""Propagate the correlation envelope via OTEL baggage.
99+
100+
*session_id* correlates custom spans to the AgentCore session; *user_id*
101+
and *repo* (#245) carry the platform identity and target repo so baggage
102+
survives across pipeline phases on the task thread. Empty/None fields are
103+
not set — so this runs (and is useful) even when *session_id* is empty but
104+
the identity is known. *repo* is None for repo-less workflows (#248 Phase 3).
81105
82106
The attached context is intentionally not detached: the background thread
83107
runs a single task then exits, so the context is garbage-collected with the
84108
thread.
85109
"""
86-
ctx = baggage.set_baggage("session.id", session_id)
110+
ctx = context.get_current()
111+
if session_id:
112+
ctx = baggage.set_baggage("session.id", session_id, context=ctx)
113+
if user_id:
114+
ctx = baggage.set_baggage("user.id", user_id, context=ctx)
115+
if repo:
116+
ctx = baggage.set_baggage("repo.url", repo, context=ctx)
87117
context.attach(ctx) # token not stored — thread-scoped lifetime

agent/src/pipeline.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,16 @@ def _resolve_overall_task_status(
469469
if agent_status in ("success", "end_turn") and build_ok:
470470
return "success", err
471471

472+
# #251 carry-path: a hook may have detected an environmental blocker mid-run
473+
# (egress denial, policy fail-closed) that the SDK surfaced only as a generic
474+
# failure or as a missing ResultMessage. Promote the canonical
475+
# ``BLOCKED[<kind>]: …`` reason so the CDK classifier attaches a precise
476+
# remedy. Import locally to avoid a module-load cycle (hooks imports
477+
# pipeline-adjacent modules).
478+
from hooks import last_blocker_reason
479+
480+
blocker = last_blocker_reason()
481+
472482
if agent_status == "unknown":
473483
if pr_url:
474484
log(
@@ -480,10 +490,17 @@ def _resolve_overall_task_status(
480490
"INFO",
481491
"No ResultMessage from SDK; build_ok=True (informational; task still failed)",
482492
)
493+
# An egress denial that kills the agent's outbound calls is a likely
494+
# cause of a missing ResultMessage — prefer the specific blocker reason
495+
# over the generic SDK-no-result message when both are present.
496+
if blocker and not err:
497+
return "error", blocker
483498
merged = f"{err}; {_SDK_NO_RESULT_MESSAGE}" if err else _SDK_NO_RESULT_MESSAGE
484499
return "error", merged
485500

486501
if not err:
502+
if blocker:
503+
return "error", blocker
487504
err = f"Task did not succeed (agent_status={agent_status!r}, build_ok={build_ok})"
488505
return "error", err
489506

@@ -662,13 +679,26 @@ def run_task(
662679
"repo.url": config.repo_url,
663680
"issue.number": config.issue_number,
664681
"agent.model": config.anthropic_model,
682+
# Correlation envelope (#245): user.id joins agent spans to
683+
# orchestrator logs by the platform identity, not just task/repo.
684+
**({"user.id": config.user_id} if config.user_id else {}),
665685
},
666686
) as root_span:
667687
task_state.write_running(config.task_id)
668688
task_state.write_heartbeat(config.task_id)
669689

670690
agent_result: AgentResult | None = None
671-
progress = _ProgressWriter(config.task_id, trace=trace)
691+
progress = _ProgressWriter(
692+
config.task_id, trace=trace, user_id=config.user_id, repo=config.repo_url
693+
)
694+
# #251: clear any blocker latched by a prior task. The agent container
695+
# is one-task-per-process today, but the FastAPI server thread-pool can
696+
# in principle dispatch a second run_task in the same process — reset
697+
# here so a stale BLOCKED[...] reason can never leak into this task's
698+
# terminal error_message (the latch is a scalar, not task_id-keyed).
699+
from hooks import reset_blocker_reason
700+
701+
reset_blocker_reason()
672702
# --trace accumulator (design §10.1): when the task opted into
673703
# trace, ``_TrajectoryWriter`` keeps an in-memory copy of each
674704
# event so the pipeline can gzip+upload the full trajectory to
@@ -794,7 +824,7 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None:
794824

795825
# Setup repo (deterministic pre-hooks)
796826
with task_span("task.repo_setup") as setup_span:
797-
setup = setup_repo(config)
827+
setup = setup_repo(config, progress=progress)
798828
setup_span.set_attribute("build.before", setup.build_before)
799829
progress.write_agent_milestone(
800830
"repo_setup_complete",

0 commit comments

Comments
 (0)