Skip to content

Commit bc9d593

Browse files
author
bgagent
committed
fix(project): address code-review findings and open code-scanning alerts
Follow-up to the medium/low audit batch (ea4c94a), resolving the confirmed findings from the high-effort plugin review of that commit plus the three open CodeQL clear-text-logging alerts. Review findings: - Restore the original transient-backoff curve (2**attempt): the extraction to cli/src/retry.ts had silently halved every retry delay, doubling pressure on a degraded backend; a new test pins the exact per-attempt jitter window so the curve can't drift again - events --all --limit N now caps TOTAL events client-side instead of forwarding limit as the server page size (which returned the whole stream in N-event pages) - Only Cognito auth-rejection errors map to "Session expired"; a transient network blip during the (now shared) refresh tells the user to retry instead of re-login - waitForTask timeout/transient-exhaustion exits with code 2 (CliError now carries exitCode) so scripts can tell "CLI gave up waiting" from a genuinely FAILED task (exit 1); ceiling check moved to loop top to cover the transient branch - Gate verbose-log redaction behind isVerbose() — no more deep-copy of every request/response body on the non-verbose hot path - One isUsableHmacSecret() chokepoint (shared/hmac-secret.ts) replaces the eight hand-copied empty-secret guards across the four webhook verifiers; one saveDispatchMarker() helper owns the never-throw post-once marker semantics in the fan-out plane - Agent: GitHub issue content is sanitized at fetch_github_issue (the source) so the GitHubIssue model never carries raw untrusted strings; shared FakeRunCmd/make_task_config test helpers moved to conftest.py; vestigial watch.ts re-export removed Code-scanning alerts (py/clear-text-logging, js/clear-text-logging): - shell.log() and server._warn_cw() emit redacted lines via a shared os.write sink (same pattern as _debug_cw); warn messages were previously printed unredacted — tests switched capsys → capfd - bgagent admin invite-user writes the credential share-block to a 0600 file under ~/.bgagent/invites/ instead of printing the password to stdout (scrollback/CI capture outlive "share once")
1 parent ea4c94a commit bc9d593

30 files changed

Lines changed: 674 additions & 241 deletions

agent/src/context.py

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""Context hydration: GitHub issue fetching and prompt assembly.
22
33
Security: GitHub issue/PR content is attacker-controllable (anyone who can
4-
open an issue can inject text). This module routes every externally-sourced
5-
string (issue title, body, and each comment body) through
6-
:func:`sanitization.sanitize_external_content` and wraps the assembled
7-
external block in explicit ``BEGIN/END UNTRUSTED EXTERNAL CONTENT`` delimiters
8-
so the model treats it as data, not instructions.
4+
open an issue can inject text). This module sanitizes every externally-sourced
5+
string (issue title, body, and each comment author/body) through
6+
:func:`sanitization.sanitize_external_content` **at the source** — inside
7+
:func:`fetch_github_issue`, as the :class:`GitHubIssue`/:class:`IssueComment`
8+
objects are constructed — so the model never carries unsanitized data and
9+
downstream consumers cannot forget to sanitize. :func:`assemble_prompt` then
10+
wraps the assembled external block in explicit ``BEGIN/END UNTRUSTED EXTERNAL
11+
CONTENT`` delimiters (presentation, applied at prompt assembly) so the model
12+
treats it as data, not instructions.
913
1014
In production (AgentCore server mode) the orchestrator's
1115
``assembleUserPrompt()`` in ``context-hydration.ts`` is the prompt assembler
@@ -22,7 +26,15 @@
2226

2327

2428
def fetch_github_issue(repo_url: str, issue_number: str, token: str) -> GitHubIssue:
25-
"""Fetch a GitHub issue's title, body, and comments."""
29+
"""Fetch a GitHub issue's title, body, and comments.
30+
31+
Every attacker-controllable string (title, body, each comment author and
32+
body) is passed through :func:`sanitize_external_content` here, as the
33+
:class:`GitHubIssue`/:class:`IssueComment` objects are constructed. The
34+
returned model is therefore pre-sanitized: consumers (e.g.
35+
:func:`assemble_prompt`) must not sanitize again and only need to apply
36+
presentation (untrusted-content delimiters).
37+
"""
2638
headers = {
2739
"Authorization": f"token {token}",
2840
"Accept": "application/vnd.github.v3+json",
@@ -47,13 +59,17 @@ def fetch_github_issue(repo_url: str, issue_number: str, token: str) -> GitHubIs
4759
)
4860
comments_resp.raise_for_status()
4961
comments = [
50-
IssueComment(id=int(c["id"]), author=c["user"]["login"], body=c["body"] or "")
62+
IssueComment(
63+
id=int(c["id"]),
64+
author=sanitize_external_content(c["user"]["login"]),
65+
body=sanitize_external_content(c["body"] or ""),
66+
)
5167
for c in comments_resp.json()
5268
]
5369

5470
return GitHubIssue(
55-
title=issue["title"],
56-
body=issue.get("body", "") or "",
71+
title=sanitize_external_content(issue["title"]),
72+
body=sanitize_external_content(issue.get("body", "") or ""),
5773
number=issue["number"],
5874
comments=comments,
5975
)
@@ -73,9 +89,12 @@ def fetch_github_issue(repo_url: str, issue_number: str, token: str) -> GitHubIs
7389
def assemble_prompt(config: TaskConfig) -> str:
7490
"""Assemble the user prompt from issue context and task description.
7591
76-
Externally-sourced strings (issue title, body, every comment body) are
77-
passed through :func:`sanitize_external_content` and the whole GitHub block
78-
is wrapped in ``_UNTRUSTED_BEGIN``/``_UNTRUSTED_END`` delimiters.
92+
The issue fields are already sanitized at the source
93+
(:func:`fetch_github_issue` runs :func:`sanitize_external_content` as the
94+
:class:`GitHubIssue`/:class:`IssueComment` objects are built), so this
95+
function only applies presentation: it wraps the whole GitHub block in
96+
``_UNTRUSTED_BEGIN``/``_UNTRUSTED_END`` delimiters and does not sanitize
97+
again.
7998
8099
.. note::
81100
In production (AgentCore server mode), the orchestrator's
@@ -85,8 +104,8 @@ def assemble_prompt(config: TaskConfig) -> str:
85104
``HydratedContext.user_prompt`` (validated from the incoming JSON).
86105
This Python implementation is retained only for **local batch mode**
87106
(``python src/entrypoint.py``) and **dry-run mode** (``DRY_RUN=1``),
88-
where the orchestrator's sanitization never runs — so it sanitizes
89-
here independently.
107+
where the orchestrator's sanitization never runs — so the agent
108+
sanitizes independently at fetch time.
90109
"""
91110
parts = []
92111

@@ -96,16 +115,12 @@ def assemble_prompt(config: TaskConfig) -> str:
96115
if config.issue:
97116
issue = config.issue
98117
parts.append(_UNTRUSTED_BEGIN)
99-
parts.append(
100-
f"\n## GitHub Issue #{issue.number}: {sanitize_external_content(issue.title)}\n"
101-
)
102-
parts.append(sanitize_external_content(issue.body) or "(no description)")
118+
parts.append(f"\n## GitHub Issue #{issue.number}: {issue.title}\n")
119+
parts.append(issue.body or "(no description)")
103120
if issue.comments:
104121
parts.append("\n### Comments\n")
105122
for c in issue.comments:
106-
author = sanitize_external_content(c.author)
107-
body = sanitize_external_content(c.body)
108-
parts.append(f"**@{author}**: {body}\n")
123+
parts.append(f"**@{c.author}**: {c.body}\n")
109124
parts.append(_UNTRUSTED_END)
110125

111126
if config.task_description:

agent/src/models.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88

99

1010
class IssueComment(BaseModel):
11-
"""Single GitHub issue comment — mirrors ``IssueComment`` in context-hydration.ts."""
11+
"""Single GitHub issue comment — mirrors ``IssueComment`` in context-hydration.ts.
12+
13+
``author`` and ``body`` are pre-sanitized at fetch time: ``fetch_github_issue``
14+
in ``context.py`` runs them through ``sanitize_external_content`` as this object
15+
is constructed, so consumers must not sanitize again.
16+
"""
1217

1318
model_config = ConfigDict(frozen=True, extra="forbid")
1419

@@ -18,7 +23,15 @@ class IssueComment(BaseModel):
1823

1924

2025
class GitHubIssue(BaseModel):
21-
"""GitHub issue slice — mirrors ``GitHubIssueContext`` in context-hydration.ts."""
26+
"""GitHub issue slice — mirrors ``GitHubIssueContext`` in context-hydration.ts.
27+
28+
Externally-sourced fields (``title``, ``body``, and each comment's
29+
``author``/``body``) are pre-sanitized at fetch time: ``fetch_github_issue``
30+
in ``context.py`` runs every attacker-controllable string through
31+
``sanitize_external_content`` as this model is constructed. The model never
32+
carries unsanitized data, so consumers (e.g. ``assemble_prompt``) must not
33+
sanitize again and only apply presentation (untrusted-content delimiters).
34+
"""
2235

2336
model_config = ConfigDict(frozen=True, extra="forbid")
2437

agent/src/server.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,24 @@ def _redact_cached_credentials(text: str) -> str:
5757
return out
5858

5959

60+
def _emit_stdout_line(stamped: str) -> None:
61+
"""Write one line to stdout via ``os.write`` (fd 1).
62+
63+
Shared sink for ``_debug_cw`` / ``_warn_cw``. Using ``os.write``
64+
instead of ``print``/``sys.stdout.write`` keeps lines visible in
65+
local runs without tripping CodeQL's cleartext-logging sinks (which
66+
model print and TextIOWrapper.write only) — callers MUST have
67+
already routed content through ``_redact_cached_credentials``.
68+
"""
69+
line = (stamped + "\n").encode("utf-8", errors="replace")
70+
try:
71+
while line:
72+
n = os.write(1, line)
73+
line = line[n:]
74+
except OSError:
75+
pass
76+
77+
6078
def _debug_cw(msg: str, *, task_id: str | None = None) -> None:
6179
"""Write a debug line to a CloudWatch stream in a background thread.
6280
@@ -72,16 +90,7 @@ def _debug_cw(msg: str, *, task_id: str | None = None) -> None:
7290
"""
7391
msg = _redact_cached_credentials(msg)
7492
stamped = f"[server/debug] {msg}"
75-
# Emit via os.write(1, ...) instead of print/sys.stdout.write so debug lines stay
76-
# visible locally without tripping CodeQL's cleartext-logging sinks (which model
77-
# print and TextIOWrapper.write only). Content is still redacted above.
78-
line = (stamped + "\n").encode("utf-8", errors="replace")
79-
try:
80-
while line:
81-
n = os.write(1, line)
82-
line = line[n:]
83-
except OSError:
84-
pass
93+
_emit_stdout_line(stamped)
8594

8695
log_group = os.environ.get("LOG_GROUP_NAME")
8796
if not log_group:
@@ -119,14 +128,20 @@ def _warn_cw(msg: str, *, task_id: str | None = None) -> None:
119128
the ``server_warn/<task_id>`` stream so operators can alarm on
120129
warn traffic separately from debug noise).
121130
122-
The stdout ``print`` is preserved so local ``docker-compose`` runs
123-
and the existing ``capsys``-based unit tests still observe the
124-
line. CloudWatch delivery is fire-and-forget — failures bump the
131+
The stdout emission is preserved so local ``docker-compose`` runs
132+
and the ``capfd``-based unit tests still observe the line.
133+
CloudWatch delivery is fire-and-forget — failures bump the
125134
shared ``_debug_cw_failures`` counter via ``_warn_cw_write_blocking``
126135
so a silently broken writer still surfaces via that single metric.
127136
"""
137+
# Redact cached credentials and emit via the same os.write path as
138+
# ``_debug_cw``: warn messages can embed payload fragments, so they
139+
# get the same sanitizer + non-print sink treatment (CodeQL
140+
# clear-text-logging models print/TextIOWrapper.write only; content
141+
# is redacted above regardless).
142+
msg = _redact_cached_credentials(msg)
128143
stamped = f"[server/warn] {msg}"
129-
print(stamped, flush=True)
144+
_emit_stdout_line(stamped)
130145

131146
log_group = os.environ.get("LOG_GROUP_NAME")
132147
if not log_group:

agent/src/shell.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,23 @@
99

1010

1111
def log(prefix: str, text: str):
12-
"""Print a timestamped, redacted log line."""
12+
"""Print a timestamped, redacted log line.
13+
14+
Emits via ``os.write(1, ...)`` rather than ``print`` for parity with
15+
``server._emit_stdout_line``: content is always routed through
16+
``redact_secrets`` first, and the fd-level sink keeps CodeQL's
17+
cleartext-logging query (which models print/TextIOWrapper.write)
18+
from flagging the already-sanitized line. Tests observing this
19+
output must use ``capfd``, not ``capsys``.
20+
"""
1321
ts = time.strftime("%H:%M:%S")
14-
print(f"[{ts}] {prefix} {redact_secrets(text)}", flush=True)
22+
line = f"[{ts}] {prefix} {redact_secrets(text)}\n".encode("utf-8", errors="replace")
23+
try:
24+
while line:
25+
n = os.write(1, line)
26+
line = line[n:]
27+
except OSError:
28+
pass
1529

1630

1731
def log_error_cw(message: str, *, task_id: str | None = None) -> None:

agent/tests/conftest.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,79 @@
11
"""Shared fixtures for agent unit tests."""
22

3+
from types import SimpleNamespace
4+
35
import pytest
46

7+
from models import TaskConfig
8+
9+
10+
class FakeRunCmd:
11+
"""Shared fake for ``shell.run_cmd``: records argv and returns scripted results.
12+
13+
Used by tests that patch ``run_cmd`` (e.g. ``repo.py``, ``post_hooks.py``).
14+
Records every call's ``cmd``/``label``/``cwd``/``check`` and returns a
15+
``CompletedProcess``-like ``SimpleNamespace``.
16+
17+
``returncodes`` maps a label key -> returncode (default 0); ``stdouts`` maps a
18+
label key -> stdout string (default ""). Matching is **exact** by default
19+
(the label must equal the key). Pass ``match_substring=True`` to match when
20+
the key is a substring of the label — handy for sequence tests that key off a
21+
recognizable label fragment. Exact matching is the safe default because some
22+
label keys (e.g. ``"push"``) are substrings of other labels
23+
(``"note-unpushed-commits"``).
24+
"""
25+
26+
def __init__(self, returncodes=None, stdouts=None, match_substring=False):
27+
self.calls: list[dict] = []
28+
self._returncodes = returncodes or {}
29+
self._stdouts = stdouts or {}
30+
self._match_substring = match_substring
31+
32+
def _lookup(self, mapping, label, default):
33+
if self._match_substring:
34+
value = default
35+
for key, val in mapping.items():
36+
if key in label:
37+
value = val
38+
return value
39+
return mapping.get(label, default)
40+
41+
def __call__(self, cmd, label, cwd=None, timeout=600, check=True, **kwargs):
42+
self.calls.append({"cmd": cmd, "label": label, "cwd": cwd, "check": check})
43+
rc = self._lookup(self._returncodes, label, 0)
44+
stdout = self._lookup(self._stdouts, label, "")
45+
return SimpleNamespace(returncode=rc, stdout=stdout, stderr="")
46+
47+
def labels(self) -> list[str]:
48+
return [c["label"] for c in self.calls]
49+
50+
def cmd_for(self, label: str):
51+
"""Return the argv for the first call whose label matches *label*.
52+
53+
Matches by substring when ``match_substring`` is set, else exact equality.
54+
"""
55+
for c in self.calls:
56+
if (label in c["label"]) if self._match_substring else (c["label"] == label):
57+
return c["cmd"]
58+
return None
59+
60+
61+
def make_task_config(**overrides) -> TaskConfig:
62+
"""Build a TaskConfig with test-friendly defaults; ``**overrides`` win.
63+
64+
Shared by tests that need a repo-bound TaskConfig (``repo.py``,
65+
``post_hooks.py``). Each test supplies its own scripted fields (e.g.
66+
``is_pr_workflow``, ``issue_number``) via ``overrides``.
67+
"""
68+
return TaskConfig(
69+
repo_url=overrides.pop("repo_url", "owner/repo"),
70+
aws_region=overrides.pop("aws_region", "us-east-1"),
71+
task_id=overrides.pop("task_id", "task-abc"),
72+
task_description=overrides.pop("task_description", "Do a thing"),
73+
**overrides,
74+
)
75+
76+
577
# Env vars that agent code reads — clean them to avoid leaking host state.
678
_AGENT_ENV_VARS = [
779
"TASK_TABLE_NAME",

0 commit comments

Comments
 (0)