Skip to content

Commit 130c76b

Browse files
committed
fix(pi): harden the Babysit push against a repository the agent controls
Every host-side guard before the push was computed by a git invocation the agent could influence from inside `.git/`, which is never committed and so is covered by neither the config digest nor any content check. - Diff measurement ignored `.git/info/attributes`. One line saying `* -diff` reduced a 500 KB change to `Binary files differ`: verified locally as 119 bytes reported against a real 500,147, so the 200 KB cumulative bound never fired, and the `diff` block output — the only record of what an unattended, review-comment-steered agent pushed — recorded the same nothing. Both diffs now run `--text --no-ext-diff --no-textconv` with an empty `core.attributesFile`. - `rev-list --count` and `merge-base --is-ancestor` answered about replaced history. A `refs/replace/*` mapping makes a five-commit chain report one, while the pushed SHA stays real. `GIT_NO_REPLACE_OBJECTS` is now set for both the prepare and push commands. - The push asserted HEAD's *shape* — right branch, one commit past the pin, a descendant — every part of which survives `commit --amend` to a different tree. So the bounds, the quoted-path refusal, the `.github/` refusal and the reported diff could all describe a commit other than the one pushed. The validated SHA is now passed in and the refspec names it. - The digest comparison resolved `cat`, `sha256sum` and `cut` through `$PATH` in the same script that invokes git absolutely for exactly that reason, and was handed the expected digest in its own environment — so a shim could print it. The utilities are now absolute. - The clone left the token in `.git/config` when the pinned-SHA assertion failed, because `set -e` aborted before `remote set-url`. Reordered. - `changedFiles` reached the block output unscrubbed on the Babysit path, where Create PR already scrubs it. Names are agent-chosen. These reduce exposure; they do not bound it. E2B runs each command as `bash -l`, so a login-shell profile the agent writes executes ahead of the next host command with the token in its environment. Removing the token from the sandbox entirely means pushing host-side through the Git Data API, which the ≤50-file / ≤200 KB bound already makes practical.
1 parent a4aff13 commit 130c76b

4 files changed

Lines changed: 145 additions & 40 deletions

File tree

apps/sim/executor/handlers/pi/babysit-backend.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -516,14 +516,43 @@ describe('runBabysitPiWithOptions', () => {
516516
expect(pushCall?.command.indexOf('CURRENT_DIGEST=')).toBeLessThan(
517517
pushCall?.command.indexOf('/usr/bin/git') ?? 0
518518
)
519-
expect(pushCall?.command).toContain('HEAD:refs/heads/$HEAD_REF')
519+
// The refspec names the validated SHA, not HEAD. Asserting only HEAD's shape
520+
// left every host-side check describing a commit other than the pushed one,
521+
// because `commit --amend` preserves branch, count, and ancestry.
522+
expect(pushCall?.command).toContain('"$NEW_SHA:refs/heads/$HEAD_REF"')
523+
expect(pushCall?.command).toContain('test "$(/usr/bin/git rev-parse HEAD)" = "$NEW_SHA"')
520524
expect(pushCall?.envs).toMatchObject({
521525
GITHUB_TOKEN: 'github-secret',
522526
ORIGINAL_GIT_CONFIG_DIGEST: 'digest-1',
523527
PINNED_SHA: OLD_SHA,
528+
NEW_SHA,
529+
GIT_NO_REPLACE_OBJECTS: '1',
524530
})
525531
})
526532

533+
// A one-line `.git/info/attributes` saying `* -diff` made a 500 KB change report
534+
// ~119 bytes to the cumulative bound and wrote "Binary files differ" into the diff
535+
// the user reviews. `.git/` is never committed, so the config digest does not see it.
536+
it('measures diffs immune to repository-supplied attributes', async () => {
537+
mockFetchSnapshot.mockResolvedValue(snapshot)
538+
mockFetchThreads.mockResolvedValue({
539+
actionable: [trustedThread],
540+
skipped: [],
541+
totalUnresolved: 1,
542+
latestReview: null,
543+
})
544+
mockFetchChecks.mockResolvedValue(greenChecks)
545+
const { runner, runCalls } = makeRunner({})
546+
mockWithPiSandbox.mockImplementation(async (callback) => callback(runner))
547+
548+
await runBabysitPiWithOptions(params(), { onEvent: vi.fn() })
549+
550+
const prepare = runCalls.find(({ command }) => command.includes('__CUMULATIVE_CHANGED__'))
551+
expect(prepare?.command).toContain('core.attributesFile=/dev/null')
552+
expect(prepare?.command).toContain('--text --no-ext-diff --no-textconv')
553+
expect(prepare?.envs).toMatchObject({ GIT_NO_REPLACE_OBJECTS: '1' })
554+
})
555+
527556
it('aggregates pushed rounds while enforcing cumulative markers', async () => {
528557
mockFetchSnapshot
529558
.mockResolvedValueOnce(snapshot)

apps/sim/executor/handlers/pi/babysit-backend.ts

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -123,25 +123,36 @@ const BABYSIT_GUIDANCE =
123123
'shape: {"threads":[{"threadId":"…","classification":"fixed|false_positive|already_addressed",' +
124124
'"reply":"…"}],"summary":"optional"}. Include only fetched thread IDs.'
125125

126+
// `remote set-url` runs before the pinned-SHA assertion, not after: `set -e` aborts
127+
// the script at a failed assertion, and the clone URL carries the token, so the
128+
// original order left `https://x-access-token:<token>@…` in `.git/config` on disk
129+
// whenever the head branch moved between Create PR and this clone.
126130
const BABYSIT_CLONE_SCRIPT = `set -e
127131
git check-ref-format "refs/heads/$HEAD_REF" >/dev/null
128132
rm -rf ${REPO_DIR}
129133
git clone --no-tags --single-branch --branch "$HEAD_REF" "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" ${REPO_DIR}
130134
cd ${REPO_DIR}
131-
test "$(git rev-parse HEAD)" = "$PINNED_SHA"
132135
git remote set-url origin "https://github.com/$REPO_OWNER/$REPO_NAME.git"
136+
test "$(git rev-parse HEAD)" = "$PINNED_SHA"
133137
${GIT_CONFIG_DIGEST_LINE}`
134138

135139
/**
136140
* Stages, commits, and bounds one round's work before the separately
137141
* authenticated push.
138142
*
139-
* The name-listing diffs run with `core.quotePath=false`. Git's default quotes
140-
* any path containing a non-ASCII or special byte — `.github/workflows/évil.yml`
141-
* is emitted as `".github/workflows/\303\251vil.yml"`, leading double quote
142-
* included — which would make the host-side `.github/` refusal in
143-
* {@link finalizeRound} compare against a quoted string and miss. The byte-count
144-
* diff is deliberately left alone: it measures content, not names.
143+
* Every `git diff` here is neutralized against repository-supplied configuration,
144+
* because the agent owns the checkout and `.git/` is not part of the commit:
145+
*
146+
* - `core.quotePath=false` stops the non-ASCII escaping that made a `.github/`
147+
* path arrive as `".github/workflows/\303\251vil.yml"` and miss the host-side
148+
* prefix test. Paths Git still quotes are refused outright in
149+
* {@link finalizeRound}.
150+
* - `--text --no-ext-diff --no-textconv` and an empty `core.attributesFile` stop
151+
* a one-line `.git/info/attributes` saying `* -diff` from reducing a 500 KB
152+
* change to `Binary files differ` — which reported ~119 bytes to the cumulative
153+
* byte bound and wrote the same nothing into the diff the user reviews.
154+
* - `GIT_NO_REPLACE_OBJECTS` is set in the env so `rev-list --count` counts real
155+
* commits: a `refs/replace/*` mapping makes a five-commit chain report one.
145156
*/
146157
const BABYSIT_PREPARE_SCRIPT = `set -e
147158
cd ${REPO_DIR}
@@ -157,24 +168,39 @@ else
157168
test "$(git rev-list --count "$ROUND_BASE_SHA"..HEAD)" = "1"
158169
test "$(git symbolic-ref --short HEAD)" = "$HEAD_REF"
159170
test "$(git rev-parse "refs/heads/$HEAD_REF")" = "$(git rev-parse HEAD)"
160-
git -c core.quotePath=false diff --name-only "$INITIAL_HEAD_SHA" HEAD | sed "s/^/__CUMULATIVE_CHANGED__=/"
161-
git diff "$INITIAL_HEAD_SHA" HEAD | wc -c | tr -d ' ' | sed "s/^/__CUMULATIVE_DIFF_BYTES__=/"
162-
git -c core.quotePath=false diff --name-only "$ROUND_BASE_SHA" HEAD | sed "s/^/__CHANGED__=/"
163-
git diff "$ROUND_BASE_SHA" HEAD > ${DIFF_PATH}
171+
git -c core.quotePath=false -c core.attributesFile=/dev/null diff --name-only --no-ext-diff "$INITIAL_HEAD_SHA" HEAD | sed "s/^/__CUMULATIVE_CHANGED__=/"
172+
git -c core.attributesFile=/dev/null diff --text --no-ext-diff --no-textconv "$INITIAL_HEAD_SHA" HEAD | wc -c | tr -d ' ' | sed "s/^/__CUMULATIVE_DIFF_BYTES__=/"
173+
git -c core.quotePath=false -c core.attributesFile=/dev/null diff --name-only --no-ext-diff "$ROUND_BASE_SHA" HEAD | sed "s/^/__CHANGED__=/"
174+
git -c core.attributesFile=/dev/null diff --text --no-ext-diff --no-textconv "$ROUND_BASE_SHA" HEAD > ${DIFF_PATH}
164175
git rev-parse HEAD | sed "s/^/__NEW_SHA__=/"
165176
test -z "$(git status --porcelain)"
166177
echo "__NEEDS_PUSH__=1"
167178
fi`
168179

180+
/**
181+
* The only token-bearing command after the clone.
182+
*
183+
* `NEW_SHA` pins the push to the exact commit the host validated. Without it the
184+
* script asserted only HEAD's *shape* — right branch, one commit past the pin, a
185+
* descendant — all of which still hold after a `commit --amend` to a different
186+
* tree, so every host-side check (bounds, quoted paths, `.github/`, the reported
187+
* diff) described a commit other than the one pushed. The refspec names the SHA
188+
* for the same reason.
189+
*
190+
* The shell utilities are absolute for the reason `git` already was: `$PATH` is
191+
* writable by an agent that has had root in this sandbox for previous rounds, and
192+
* a shim named `sha256sum` would otherwise be handed the digest it must produce.
193+
*/
169194
const BABYSIT_PUSH_SCRIPT = `set -e
170195
cd ${REPO_DIR}
171-
CURRENT_DIGEST=$(cat .git/config .git/config.worktree 2>/dev/null | sha256sum | cut -d' ' -f1)
196+
CURRENT_DIGEST=$(/bin/cat .git/config .git/config.worktree 2>/dev/null | /usr/bin/sha256sum | /usr/bin/cut -d' ' -f1)
172197
test "$CURRENT_DIGEST" = "$ORIGINAL_GIT_CONFIG_DIGEST"
198+
test "$(/usr/bin/git rev-parse HEAD)" = "$NEW_SHA"
173199
test "$(/usr/bin/git symbolic-ref --short HEAD)" = "$HEAD_REF"
174-
test "$(/usr/bin/git rev-parse "refs/heads/$HEAD_REF")" = "$(/usr/bin/git rev-parse HEAD)"
175-
test "$(/usr/bin/git rev-list --count "$PINNED_SHA"..HEAD)" = "1"
176-
/usr/bin/git merge-base --is-ancestor "$PINNED_SHA" HEAD
177-
/usr/bin/git -c core.hooksPath=/dev/null -c credential.helper= -c core.fsmonitor= push "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" "HEAD:refs/heads/$HEAD_REF"
200+
test "$(/usr/bin/git rev-parse "refs/heads/$HEAD_REF")" = "$NEW_SHA"
201+
test "$(/usr/bin/git rev-list --count "$PINNED_SHA".."$NEW_SHA")" = "1"
202+
/usr/bin/git merge-base --is-ancestor "$PINNED_SHA" "$NEW_SHA"
203+
/usr/bin/git -c core.hooksPath=/dev/null -c credential.helper= -c core.fsmonitor= push "https://x-access-token:$GITHUB_TOKEN@github.com/$REPO_OWNER/$REPO_NAME.git" "$NEW_SHA:refs/heads/$HEAD_REF"
178204
echo "__PUSHED__=1"`
179205

180206
interface BabysitBackendOptions {
@@ -524,6 +550,7 @@ async function finalizeRound(
524550
HEAD_REF: snapshot.headRef,
525551
INITIAL_HEAD_SHA: initialHeadSha,
526552
ROUND_BASE_SHA: roundBaseSha,
553+
GIT_NO_REPLACE_OBJECTS: '1',
527554
},
528555
timeoutMs: FINALIZE_TIMEOUT_MS,
529556
}),
@@ -589,7 +616,9 @@ async function finalizeRound(
589616
REPO_NAME: params.repo,
590617
HEAD_REF: snapshot.headRef,
591618
PINNED_SHA: snapshot.headSha,
619+
NEW_SHA: newSha,
592620
ORIGINAL_GIT_CONFIG_DIGEST: gitConfigDigest,
621+
GIT_NO_REPLACE_OBJECTS: '1',
593622
},
594623
timeoutMs: FINALIZE_TIMEOUT_MS,
595624
}),
@@ -994,8 +1023,15 @@ export async function runBabysitPiWithOptions(
9941023
roundBaseSha = finalized.newSha
9951024
progress.commitsPushed += 1
9961025
githubWriteOccurred = true
1026+
// Scrubbed here rather than in `finalizeRound`, which needs the literal
1027+
// paths for its `.github/` and quoted-path refusals. File names are
1028+
// agent-chosen, so a file named after a key would otherwise reach the
1029+
// block output verbatim — Create PR already scrubs its equivalent.
9971030
progress.changedFiles = [
998-
...new Set([...progress.changedFiles, ...finalized.changedFiles]),
1031+
...new Set([
1032+
...progress.changedFiles,
1033+
...finalized.changedFiles.map((file) => scrubPiSecrets(file, secrets)),
1034+
]),
9991035
]
10001036
progress.diff = capDiff([progress.diff, finalized.diff].filter(Boolean).join('\n'))
10011037
lastKnownChecksGreen = ![...initialRequirements.values()].some((required) => required)

apps/sim/tools/github/job_logs.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ function logResponse(body: string): Response {
1616
return new Response(body, { headers: { 'Content-Type': 'text/plain' } })
1717
}
1818

19-
/** A storage host that honoured the suffix range: 206 plus the full size. */
19+
/** A storage host that honoured the suffix range: 206 plus the served window. */
2020
function partialLogResponse(body: string, totalBytes: number): Response {
2121
return new Response(body, {
2222
status: 206,
@@ -100,6 +100,25 @@ describe('github_job_logs', () => {
100100
})
101101
})
102102

103+
// A suffix range asking for more bytes than the log holds is satisfied with the
104+
// WHOLE log, still as a 206 — `Content-Range` starts at 0. Trimming the first line
105+
// there would delete a real line, and this is the common case for a job that
106+
// failed fast and logged little.
107+
it('keeps the first line when a 206 served the whole log', async () => {
108+
const log = 'first line\nsecond line\n'
109+
110+
const result = await jobLogsTool.transformResponse!(
111+
partialLogResponse(log, Buffer.byteLength(log)),
112+
{ ...BASE_PARAMS, maxCharacters: 20_000 }
113+
)
114+
115+
expect(result.output).toEqual({
116+
logs: log,
117+
truncated: false,
118+
totalBytes: Buffer.byteLength(log),
119+
})
120+
})
121+
103122
// A host that ignores the range answers 200 with the whole body, so the local
104123
// slice has to remain the fallback rather than an assumption about partiality.
105124
it('falls back to the local slice when the range is ignored', async () => {

apps/sim/tools/github/job_logs.ts

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -28,44 +28,58 @@ function jobLogsPath(owner: string, repo: string, jobId: number): string {
2828
return `${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/actions/jobs/${jobId}/logs`
2929
}
3030

31+
/** Byte offsets from a `Content-Range: bytes <start>-<end>/<total>` header. */
32+
interface ContentRange {
33+
start: number
34+
total: number | null
35+
}
36+
3137
/**
32-
* Total size from a `Content-Range: bytes <start>-<end>/<total>` header.
38+
* Parses the served byte window.
3339
*
34-
* `null` for an unsatisfied-range form, an unknown total, an unparsable value, or
35-
* an absent header — all of which mean the full size is simply unknown here.
40+
* `null` for an unsatisfied-range form, an unparsable value, or an absent header.
41+
* The `start` matters as much as the total: a suffix range asking for more bytes
42+
* than the log contains is satisfied with the *whole* representation, still as a
43+
* 206, and only `start === 0` distinguishes that from a window that genuinely cut
44+
* into the middle of the log.
3645
*/
37-
function parseContentRangeTotal(header: string | null): number | null {
38-
const total = header?.match(/^bytes\s+\d+-\d+\/(\d+)$/)?.[1]
39-
if (!total) return null
40-
const parsed = Number(total)
41-
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null
46+
function parseContentRange(header: string | null): ContentRange | null {
47+
const match = header?.match(/^bytes\s+(\d+)-\d+\/(\d+|\*)$/)
48+
if (!match) return null
49+
const start = Number(match[1])
50+
if (!Number.isSafeInteger(start) || start < 0) return null
51+
const total = match[2] === '*' ? null : Number(match[2])
52+
return {
53+
start,
54+
total: total !== null && Number.isSafeInteger(total) && total >= 0 ? total : null,
55+
}
4256
}
4357

4458
/**
4559
* The tail is what matters: a failing job reports its error at the end.
4660
*
47-
* A ranged response already *is* the tail, so it is only trimmed at the first
48-
* line break — the byte window almost always cuts mid-line, and it can also split
49-
* a multi-byte character into a replacement char. A full response is sliced
50-
* locally instead, which is the path taken whenever the storage host ignores the
51-
* range and answers 200.
61+
* A window that starts partway into the log is trimmed at its first line break,
62+
* because the byte boundary almost always lands mid-line and can split a
63+
* multi-byte character. A window starting at zero is the whole log — the storage
64+
* host satisfied a suffix range larger than the content — so it is treated
65+
* exactly like an unranged body, which is the common case for a job that failed
66+
* fast and logged little.
5267
*/
5368
function logTail(
5469
text: string,
5570
maxCharacters: number,
56-
partial: boolean,
57-
totalBytes: number | null
71+
range: ContentRange | null
5872
): { logs: string; truncated: boolean; totalBytes: number | null } {
59-
if (!partial) {
73+
if (!range || range.start === 0) {
6074
return {
6175
logs: text.slice(-maxCharacters),
6276
truncated: text.length > maxCharacters,
63-
totalBytes: totalBytes ?? Buffer.byteLength(text),
77+
totalBytes: range?.total ?? Buffer.byteLength(text),
6478
}
6579
}
6680
const firstBreak = text.indexOf('\n')
6781
const trimmed = firstBreak === -1 ? text : text.slice(firstBreak + 1)
68-
return { logs: trimmed.slice(-maxCharacters), truncated: true, totalBytes }
82+
return { logs: trimmed.slice(-maxCharacters), truncated: true, totalBytes: range.total }
6983
}
7084

7185
export const jobLogsTool: ToolConfig<JobLogsParams, JobLogsResponse> = {
@@ -133,13 +147,20 @@ export const jobLogsTool: ToolConfig<JobLogsParams, JobLogsResponse> = {
133147
stripAuthOnRedirect: true,
134148
},
135149

150+
/**
151+
* A suffix range against a zero-length log is unsatisfiable, so such a job
152+
* surfaces as a 416 tool error rather than as empty `logs`. Not special-cased
153+
* here because the executor rejects a non-2xx before `transformResponse` runs,
154+
* and an Actions job log is never truly empty — the runner writes its own
155+
* setup lines before any step does.
156+
*/
136157
transformResponse: async (response, params) => {
137158
const maxCharacters = resolveMaxCharacters(params?.maxCharacters)
138-
const partial = response.status === 206
139-
const totalBytes = parseContentRangeTotal(response.headers.get('content-range'))
159+
const range =
160+
response.status === 206 ? parseContentRange(response.headers.get('content-range')) : null
140161
return {
141162
success: true,
142-
output: logTail(await response.text(), maxCharacters, partial, totalBytes),
163+
output: logTail(await response.text(), maxCharacters, range),
143164
}
144165
},
145166

0 commit comments

Comments
 (0)