Skip to content

Commit d6b00df

Browse files
krokokobgagent
andauthored
fix(project): fix multiple issues identified (aws-samples#324)
* chore(project): various fixes * chore(project): remove cdk json edit --------- Co-authored-by: bgagent <bgagent@noreply.github.com>
1 parent b05588b commit d6b00df

19 files changed

Lines changed: 629 additions & 82 deletions

File tree

.github/workflows/auto-approve.yml

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@ name: auto-approve
33
# upgrade-main dependency PRs). pull_request_target is intentional and safe
44
# here: the job never checks out or executes PR code — it only calls the
55
# review API. Branch protection still requires the build workflow to pass.
6+
#
7+
# Trigger is `labeled` ONLY: each approval requires an explicit label event
8+
# from someone with triage+ permission. Approving on `synchronize` would
9+
# re-approve arbitrary future commits pushed to an already-labeled PR with no
10+
# human re-review — combined with Mergify's auto-merge that is an
11+
# unreviewed-merge path. The Mergify `dismiss stale approvals` rule is the
12+
# other half of this invariant: a push invalidates the bot's prior approval,
13+
# and re-approval requires removing and re-adding the label (deliberate
14+
# friction: new content needs a fresh, explicit trigger).
615
on:
716
pull_request_target:
817
types:
918
- labeled
10-
- unlabeled
11-
- opened
12-
- synchronize
13-
- reopened
14-
- ready_for_review
15-
- review_requested
1619
permissions:
1720
actions: none
1821
attestations: none
@@ -34,7 +37,11 @@ jobs:
3437
runs-on: ubuntu-latest
3538
permissions:
3639
pull-requests: write
37-
if: contains(github.event.pull_request.labels.*.name, 'auto-approve')
40+
# Only act on the auto-approve label itself, and never on fork PRs —
41+
# a fork head can be force-pushed by the fork owner after labeling.
42+
if: |
43+
github.event.label.name == 'auto-approve' &&
44+
github.event.pull_request.head.repo.full_name == github.repository
3845
steps:
3946
- uses: hmarr/auto-approve-action@f0939ea97e9205ef24d872e76833fa908a770363 # v4.0.0
4047
with:

.mergify.yml

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ The `@backgroundagent/cli` package provides the `bgagent` executable for submitt
8282
- `src/api-client.ts` — HTTP client wrapping `fetch` with auth header injection
8383
- `src/auth.ts` — Cognito login, token caching (`~/.bgagent/credentials.json`), auto-refresh
8484
- `src/config.ts` — Read/write `~/.bgagent/config.json`
85-
- `src/types.ts` — API request/response types (mirrored from `cdk/src/handlers/shared/types.ts`), including `TaskType` (`new_task` | `pr_iteration` | `pr_review`)
85+
- `src/types.ts` — API request/response types (mirrored from `cdk/src/handlers/shared/types.ts`), including `workflow_ref` / `ResolvedWorkflow` (workflow ids like `coding/new-task-v1`, `coding/pr-iteration-v1`, `coding/pr-review-v1`; replaced the former `TaskType` enum, #248)
8686
- `src/format.ts` — Output formatting (table, detail view, JSON)
8787
- `src/debug.ts` — Verbose/debug logging (`--verbose` flag)
8888
- `src/errors.ts``CliError` and `ApiError` classes

agent/src/hooks.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import re
2525
import time
2626
from collections.abc import Callable
27+
from datetime import UTC
2728
from typing import TYPE_CHECKING, Any
2829

2930
import nudge_reader
@@ -805,7 +806,12 @@ def _remaining_maxlifetime_s() -> int | None:
805806
else:
806807
from datetime import datetime
807808

808-
started_epoch = int(datetime.strptime(started_at, "%Y-%m-%dT%H:%M:%SZ").timestamp())
809+
# The trailing Z means UTC; strptime returns a naive datetime whose
810+
# .timestamp() would otherwise be interpreted in the container's
811+
# local TZ, skewing remaining-lifetime math by the UTC offset.
812+
started_epoch = int(
813+
datetime.strptime(started_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=UTC).timestamp()
814+
)
809815
except (ValueError, AttributeError):
810816
return None
811817
elapsed = int(time.time()) - started_epoch

agent/src/output_scanner.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,15 @@ class ScanResult:
4242
re.IGNORECASE,
4343
),
4444
),
45-
# GitHub tokens (PAT, OAuth, App, user-to-server, fine-grained)
46-
("GITHUB_TOKEN", re.compile(r"(?:ghp|gho|ghs|ghu)_[a-zA-Z0-9]{36}")),
45+
# GitHub tokens (PAT, OAuth, App, user-to-server, refresh, fine-grained).
46+
# Length is variable across token generations — match 36+ chars rather
47+
# than exactly 36 so newer/longer formats are still caught.
48+
("GITHUB_TOKEN", re.compile(r"(?:ghp|gho|ghs|ghu|ghr)_[a-zA-Z0-9]{36,}")),
4749
("GITHUB_PAT", re.compile(r"github_pat_[a-zA-Z0-9_]{22,}")),
50+
# Credentials embedded in git remote URLs (https://x-access-token:TOKEN@github.com/...).
51+
# The agent authenticates via env-based credential helper, but tool output
52+
# may still echo such URLs from other sources.
53+
("GITHUB_URL_TOKEN", re.compile(r"x-access-token:[^\s@\"']+@")),
4854
# Private keys (PEM blocks)
4955
(
5056
"PRIVATE_KEY",

agent/src/repo.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,20 +44,30 @@ def setup_repo(config: TaskConfig) -> RepoSetup:
4444
label="clone",
4545
)
4646

47-
# Configure remote URL with embedded token so git push works without
48-
# credential helpers or extra auth setup inside the agent.
49-
token = config.github_token
47+
# Pin the remote to the plain https URL (no embedded credentials) and
48+
# authenticate git push via gh's credential helper. Embedding the token
49+
# in the remote URL would persist it in .git/config inside the workspace
50+
# the agent (and any code it runs) fully controls — readable by a
51+
# prompt-injected step, `git remote -v`, or anything that copies the
52+
# tree. The helper resolves credentials at call time from the
53+
# GH_TOKEN/GITHUB_TOKEN env vars the caller exports before setup_repo(),
54+
# so the token never touches disk.
5055
run_cmd(
5156
[
5257
"git",
5358
"remote",
5459
"set-url",
5560
"origin",
56-
f"https://x-access-token:{token}@github.com/{config.repo_url}.git",
61+
f"https://github.com/{config.repo_url}.git",
5762
],
5863
label="set-remote-url",
5964
cwd=repo_dir,
6065
)
66+
run_cmd(
67+
["git", "config", "--local", "credential.helper", "!gh auth git-credential"],
68+
label="configure-git-credential-helper",
69+
cwd=repo_dir,
70+
)
6171

6272
# Branch setup
6373
if config.is_pr_workflow and config.branch_name:

agent/tests/test_hooks.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ def test_matchers_with_trajectory(self):
321321
import hashlib
322322
import json as _json
323323
from collections import deque
324+
from datetime import UTC
324325
from typing import Any
325326

326327
import hooks
@@ -1412,3 +1413,44 @@ def test_stop_hook_drains_denials_when_no_cancel(self, engine_with_soft_gate):
14121413
# DDB and returns []. Denial should be injected.
14131414
assert result.get("decision") == "block"
14141415
assert "<user_denial" in result.get("reason", "")
1416+
1417+
1418+
class TestRemainingMaxlifetime:
1419+
"""_remaining_maxlifetime_s parses TASK_STARTED_AT correctly."""
1420+
1421+
def test_iso_timestamp_parsed_as_utc_regardless_of_local_tz(self, monkeypatch):
1422+
# The trailing Z means UTC. Before the fix, strptime produced a naive
1423+
# datetime whose .timestamp() used the container's local TZ, skewing
1424+
# the remaining-lifetime math by the UTC offset (wrong approval
1425+
# timeouts / spurious insufficient-lifetime denies on non-UTC hosts).
1426+
import time as time_module
1427+
from datetime import datetime
1428+
1429+
started = datetime(2026, 6, 11, 12, 0, 0, tzinfo=UTC)
1430+
monkeypatch.setenv("TASK_STARTED_AT", "2026-06-11T12:00:00Z")
1431+
monkeypatch.setenv("AGENTCORE_MAX_LIFETIME_S", "28800")
1432+
# Freeze "now" at exactly 1000s after the UTC start time.
1433+
monkeypatch.setattr(hooks.time, "time", lambda: started.timestamp() + 1000, raising=True)
1434+
# Simulate a non-UTC container: if the implementation regresses to
1435+
# local-time interpretation, the result shifts by the TZ offset.
1436+
monkeypatch.setenv("TZ", "America/New_York")
1437+
time_module.tzset()
1438+
try:
1439+
assert hooks._remaining_maxlifetime_s() == 28800 - 1000
1440+
finally:
1441+
monkeypatch.delenv("TZ")
1442+
time_module.tzset()
1443+
1444+
def test_epoch_seconds_passthrough(self, monkeypatch):
1445+
monkeypatch.setenv("TASK_STARTED_AT", "1000000")
1446+
monkeypatch.setenv("AGENTCORE_MAX_LIFETIME_S", "500")
1447+
monkeypatch.setattr(hooks.time, "time", lambda: 1000100, raising=True)
1448+
assert hooks._remaining_maxlifetime_s() == 400
1449+
1450+
def test_missing_started_at_returns_none(self, monkeypatch):
1451+
monkeypatch.delenv("TASK_STARTED_AT", raising=False)
1452+
assert hooks._remaining_maxlifetime_s() is None
1453+
1454+
def test_unparseable_started_at_returns_none(self, monkeypatch):
1455+
monkeypatch.setenv("TASK_STARTED_AT", "not-a-timestamp")
1456+
assert hooks._remaining_maxlifetime_s() is None

agent/tests/test_output_scanner.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,23 @@ def test_detects_github_fine_grained_pat(self):
6363
assert "GITHUB_PAT detected" in result.findings
6464
assert "[REDACTED-GITHUB_PAT]" in result.redacted_content
6565

66+
def test_detects_token_longer_than_36_chars(self):
67+
# Token lengths vary across generations; the pattern must not be
68+
# anchored to exactly 36 chars.
69+
content = "token: ghs_" + "A" * 48
70+
result = scan_tool_output(content)
71+
assert result.has_sensitive_content is True
72+
assert "GITHUB_TOKEN detected" in result.findings
73+
assert "ghs_" not in result.redacted_content
74+
75+
def test_detects_token_in_remote_url(self):
76+
# `git remote -v` style output with x-access-token credentials.
77+
content = "origin https://x-access-token:ghs_secret123@github.com/owner/repo.git (push)"
78+
result = scan_tool_output(content)
79+
assert result.has_sensitive_content is True
80+
assert "GITHUB_URL_TOKEN detected" in result.findings
81+
assert "ghs_secret123" not in result.redacted_content
82+
6683
# ---- Private keys ----
6784

6885
def test_detects_rsa_private_key(self):

cdk/src/handlers/shared/agentcore-browser.ts

Lines changed: 43 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,19 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
156156
}
157157
const eventWaiters: EventWaiter[] = [];
158158

159+
// Track the latest Document response status PER FRAME so we can fail fast
160+
// on 4xx/5xx (404 / 503 / auth wall pages) instead of capturing what looks
161+
// like the app but isn't. The main frame's id is only known once
162+
// Page.navigate resolves, but `Network.responseReceived` for the main
163+
// document frequently arrives BEFORE that response — so record every
164+
// Document status keyed by frameId and resolve which one is the main
165+
// document afterwards. (Recording a single "latest" status instead would
166+
// race: an early sub-frame response could be misattributed as the main
167+
// document, or the real main-document status missed entirely.)
168+
// Redirect chains re-fire for the same frameId; last write wins, which is
169+
// the final response. (Auth walls that return 200 are out of scope — #287.)
170+
const documentStatusByFrame = new Map<string, number>();
171+
159172
ws.on('message', (raw: RawData) => {
160173
const data = raw.toString();
161174
let msg: CdpMessage;
@@ -164,6 +177,19 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
164177
} catch {
165178
return;
166179
}
180+
if (msg.method === 'Network.responseReceived') {
181+
const params = msg.params as
182+
| { type?: string; frameId?: string; response?: { status?: number } }
183+
| undefined;
184+
// CDP's `Network.responseReceived` fires for every resource (HTML,
185+
// JS, CSS, images, XHR, …). Only type==='Document' responses are
186+
// candidate main-document responses.
187+
if (params?.type === 'Document' && typeof params.frameId === 'string') {
188+
const status = params.response?.status;
189+
if (typeof status === 'number') documentStatusByFrame.set(params.frameId, status);
190+
}
191+
return;
192+
}
167193
if (typeof msg.id === 'number') {
168194
const slot = pending.get(msg.id);
169195
if (slot) {
@@ -262,14 +288,6 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
262288
});
263289
}
264290

265-
// Track the main-document HTTP response so we can fail fast on 4xx/5xx
266-
// (404 / 503 / auth wall pages) instead of capturing what looks like the
267-
// app but isn't. Captured in a Network.responseReceived listener below;
268-
// checked after Page.loadEventFired but before Page.captureScreenshot.
269-
// (Auth walls that return 200 are out of scope — see issue #287.)
270-
let mainDocumentStatus: number | null = null;
271-
let mainDocumentFrameId: string | null = null;
272-
273291
try {
274292
// 1. List existing targets, find the default about:blank page.
275293
const targetsResp = await cdpSend('Target.getTargets');
@@ -293,34 +311,11 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
293311
// we wait on below AND the main-document response status. Network
294312
// has to be enabled BEFORE Page.navigate, or the response event
295313
// fires before our listener is wired and we miss the status.
314+
// (Document statuses are recorded per-frame by the single message
315+
// listener above; we resolve the main frame's status after load.)
296316
await cdpSend('Page.enable', {}, pageSessionId);
297317
await cdpSend('Network.enable', {}, pageSessionId);
298318

299-
// Tap the raw message stream for Network.responseReceived events —
300-
// we want a multi-fire listener (Document responses can appear for
301-
// redirect chains), not the one-shot waiter pattern that
302-
// eventWaiters / waitForEvent use. Records the latest matching
303-
// status; the post-load check below acts on whatever was captured.
304-
ws.on('message', (raw: RawData) => {
305-
let msg: CdpMessage;
306-
try {
307-
msg = JSON.parse(raw.toString()) as CdpMessage;
308-
} catch {
309-
return;
310-
}
311-
if (msg.method !== 'Network.responseReceived') return;
312-
const params = msg.params as
313-
| { type?: string; frameId?: string; response?: { status?: number } }
314-
| undefined;
315-
// CDP's `Network.responseReceived` fires for every resource (HTML,
316-
// JS, CSS, images, XHR, …). Only the type==='Document' event for
317-
// the navigated frame is the main-document response we care about.
318-
if (!params || params.type !== 'Document') return;
319-
if (mainDocumentFrameId && params.frameId !== mainDocumentFrameId) return;
320-
const status = params.response?.status;
321-
if (typeof status === 'number') mainDocumentStatus = status;
322-
});
323-
324319
// 4. Navigate. The response includes a `frameId`; we wait on the
325320
// `Page.loadEventFired` event below (more reliable than
326321
// `frameStoppedLoading` which can fire before navigation
@@ -330,7 +325,7 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
330325
if (navError) {
331326
throw new Error(`Page.navigate failed: ${navError}`);
332327
}
333-
mainDocumentFrameId = (navResp.result?.frameId as string | undefined) ?? null;
328+
const mainDocumentFrameId = (navResp.result?.frameId as string | undefined) ?? null;
334329

335330
// 5. Wait for the page load event. SPA-style apps may continue
336331
// fetching after this fires, so add a 2s settle wait. For
@@ -344,10 +339,21 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
344339
// perspective; the user sees a confidently-wrong screenshot of an
345340
// error page posted as the deploy preview. Throw → processor's
346341
// catch logs and skips the PR/Linear comment cleanly.
347-
// If we never captured a status (Network.responseReceived was
348-
// queued but predicate didn't match — e.g. a redirect chain that
349-
// doesn't expose the final frame), fall through and capture
342+
// The main frame's id comes from the Page.navigate response; its
343+
// Document responses were recorded per-frame by the message
344+
// listener even if they arrived before navigate resolved. If
345+
// Page.navigate returned no frameId, only an unambiguous single
346+
// recorded status is trusted — with multiple frames we cannot
347+
// tell which is the main document.
348+
// If we never captured a status (e.g. a service variant that
349+
// doesn't emit Network events), fall through and capture
350350
// optimistically; that's the pre-#287 behaviour.
351+
let mainDocumentStatus: number | null = null;
352+
if (mainDocumentFrameId !== null) {
353+
mainDocumentStatus = documentStatusByFrame.get(mainDocumentFrameId) ?? null;
354+
} else if (documentStatusByFrame.size === 1) {
355+
mainDocumentStatus = documentStatusByFrame.values().next().value ?? null;
356+
}
351357
if (mainDocumentStatus !== null && (mainDocumentStatus < 200 || mainDocumentStatus >= 300)) {
352358
throw new Error(`Preview URL returned HTTP ${mainDocumentStatus}; skipping screenshot`);
353359
}

cdk/src/handlers/shared/linear-verify.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,13 @@ export async function getLinearSecret(secretId: string, forceRefresh = false): P
5454

5555
try {
5656
const result = await sm.send(new GetSecretValueCommand({ SecretId: secretId }));
57-
if (!result.SecretString) {
57+
// Treat empty / whitespace-only SecretString as null — an empty secret
58+
// must never be used for HMAC, or HMAC('', body) becomes forgeable.
59+
// (Same guard as getGitHubWebhookSecret.)
60+
if (!result.SecretString || result.SecretString.trim() === '') {
61+
logger.error('Linear webhook secret is empty — refusing to use for HMAC', {
62+
secret_id: secretId,
63+
});
5864
secretCache.delete(secretId);
5965
return null;
6066
}
@@ -101,6 +107,14 @@ export function verifyLinearSignature(
101107
signature: string,
102108
body: string,
103109
): boolean {
110+
// Defense-in-depth: getLinearSecret already filters empty secrets, but
111+
// callers like verifyLinearRequestForWorkspace pass secrets from other
112+
// sources (per-workspace OAuth bundles) — HMAC('') must always be
113+
// rejected or an attacker can forge signatures against a misconfigured
114+
// empty secret. (Mirrors verifyGitHubSignature, PR-241 review B2.)
115+
if (!webhookSecret || webhookSecret.trim() === '') {
116+
return false;
117+
}
104118
const expected = crypto.createHmac('sha256', webhookSecret).update(body).digest('hex');
105119
try {
106120
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));

0 commit comments

Comments
 (0)