Skip to content

Commit 4441526

Browse files
abrichrclaude
andauthored
fix: harden token security, add guardrails, fix 0-test loop (#19)
Security: - Strip credentials from .git/config after clone so Claude subprocess cannot read the token from the remote URL - Re-inject credentials only during push, strip immediately after - Sanitize git error messages in commitAndPush to prevent token leaks - Use minimal env (PATH + HOME + GH_TOKEN) for gh subprocess instead of spreading all process.env vars Guardrails: - Add hard constraints to system prompt: never modify test files, package manifests, or lock files unless task explicitly requires it - Documentation-only tasks restricted to documentation files only Bug fixes: - Treat 0 tests collected as pass (failed=0 && errors=0) instead of failure, preventing 10 wasted loops on README-only changes - Sanitize Telegram <> formatting delimiters from task text before use in prompts, PR body, and commit messages - Pass github_token through to commitAndPush for authenticated push Bot deployment: - Add bot Dockerfile, fly.toml, and .env.example - Add worker .env.example Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e7287b1 commit 4441526

File tree

6 files changed

+210
-12
lines changed

6 files changed

+210
-12
lines changed

apps/bot/.env.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Wright Telegram Bot — environment variables
2+
# Copy to .env and fill in values: cp .env.example .env
3+
4+
# Telegram bot token from @BotFather
5+
BOT_TOKEN=
6+
7+
# Supabase project URL
8+
SUPABASE_URL=https://xndulzwwzioghxhfgzig.supabase.co
9+
10+
# Supabase service_role key (NOT the anon key)
11+
SUPABASE_KEY=
12+
13+
# GitHub personal access token (needs repo scope for PR creation)
14+
GITHUB_TOKEN=
15+
16+
# Comma-separated Telegram user IDs allowed to use the bot (empty = no restriction)
17+
ALLOWED_TELEGRAM_USERS=

apps/bot/Dockerfile

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# =============================================================================
2+
# Wright Bot -- Production Multi-Stage Dockerfile
3+
#
4+
# Stages:
5+
# 1. deps -- install pnpm + all workspace dependencies
6+
# 2. build -- compile TypeScript (shared + bot)
7+
# 3. runtime -- minimal image with built artifacts
8+
#
9+
# Build context: repo root (run with `docker build -f apps/bot/Dockerfile .`)
10+
# =============================================================================
11+
12+
# ---------------------------------------------------------------------------
13+
# Stage 1: Install dependencies
14+
# ---------------------------------------------------------------------------
15+
FROM node:22-slim AS deps
16+
17+
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
18+
19+
WORKDIR /app
20+
21+
# Copy only what pnpm needs for dependency resolution (layer cache)
22+
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml* turbo.json ./
23+
COPY packages/shared/package.json packages/shared/
24+
COPY apps/bot/package.json apps/bot/
25+
26+
# Install all deps (including devDependencies needed for build)
27+
RUN pnpm install --frozen-lockfile || pnpm install
28+
29+
# ---------------------------------------------------------------------------
30+
# Stage 2: Build TypeScript
31+
# ---------------------------------------------------------------------------
32+
FROM deps AS build
33+
34+
# Copy source files
35+
COPY tsconfig.base.json ./
36+
COPY packages/shared/tsconfig.json packages/shared/
37+
COPY packages/shared/src/ packages/shared/src/
38+
COPY apps/bot/tsconfig.json apps/bot/
39+
COPY apps/bot/src/ apps/bot/src/
40+
41+
# Build shared first, then bot
42+
RUN pnpm --filter @wright/shared build && pnpm --filter @wright/bot build
43+
44+
# Prune devDependencies for a leaner copy into runtime.
45+
RUN pnpm prune --prod \
46+
&& mkdir -p packages/shared/node_modules apps/bot/node_modules
47+
48+
# ---------------------------------------------------------------------------
49+
# Stage 3: Runtime
50+
# ---------------------------------------------------------------------------
51+
FROM node:22-slim AS runtime
52+
53+
WORKDIR /app
54+
55+
# Copy workspace manifests (needed for pnpm workspace resolution at runtime)
56+
COPY --from=build /app/package.json /app/pnpm-workspace.yaml ./
57+
58+
# Copy shared package (built artifacts + package.json)
59+
COPY --from=build /app/packages/shared/package.json packages/shared/
60+
COPY --from=build /app/packages/shared/dist/ packages/shared/dist/
61+
62+
# Copy bot (built artifacts + package.json)
63+
COPY --from=build /app/apps/bot/package.json apps/bot/
64+
COPY --from=build /app/apps/bot/dist/ apps/bot/dist/
65+
66+
# Copy production node_modules from pruned build stage
67+
COPY --from=build /app/node_modules/ node_modules/
68+
COPY --from=build /app/packages/shared/node_modules/ packages/shared/node_modules/
69+
COPY --from=build /app/apps/bot/node_modules/ apps/bot/node_modules/
70+
71+
# Non-root user
72+
RUN groupadd --gid 1001 wright \
73+
&& useradd --uid 1001 --gid wright --shell /bin/bash --create-home wright \
74+
&& chown -R wright:wright /app
75+
76+
USER wright
77+
78+
WORKDIR /app/apps/bot
79+
80+
CMD ["node", "dist/index.js"]

apps/bot/fly.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Fly.io configuration for the wright Telegram bot
2+
# Long-polling bot — no HTTP service needed, just a persistent process
3+
4+
app = "wright-bot"
5+
primary_region = "ord"
6+
7+
[build]
8+
dockerfile = "Dockerfile"
9+
10+
[env]
11+
NODE_ENV = "production"
12+
13+
[[vm]]
14+
memory = "256mb"
15+
cpu_kind = "shared"
16+
cpus = 1

apps/worker/.env.example

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Wright Worker — environment variables (for local development)
2+
# Copy to .env and fill in values: cp .env.example .env
3+
# In production (Fly.io), these are set via `fly secrets set`.
4+
5+
# Supabase project URL
6+
SUPABASE_URL=https://xndulzwwzioghxhfgzig.supabase.co
7+
8+
# Supabase service_role key (NOT the anon key)
9+
SUPABASE_SERVICE_ROLE_KEY=
10+
11+
# Anthropic API key for Claude sessions
12+
ANTHROPIC_API_KEY=
13+
14+
# Optional: Claude model to use (default: claude-sonnet-4-20250514)
15+
# CLAUDE_MODEL=claude-sonnet-4-20250514
16+
17+
# Optional: Max turns per Claude session loop (default: 30)
18+
# MAX_TURNS_PER_LOOP=30
19+
20+
# Optional: Test timeout in seconds (default: 300)
21+
# TEST_TIMEOUT_SECONDS=300
22+
23+
# Optional: Workspace directory for cloned repos (default: /tmp/wright-work)
24+
# WORKSPACE_DIR=/tmp/wright-work

apps/worker/src/dev-loop.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,20 @@ import { detectTestRunner, detectPackageManager, installDependencies, runTests }
1616
import { runClaudeSession } from './claude-session.js'
1717
import { existsSync, rmSync, mkdirSync } from 'fs'
1818

19+
/**
20+
* Strip Telegram HTML-like formatting delimiters (e.g. `<b>`, `</b>`, `<code>`) from
21+
* raw message text so they don't leak into prompts, PR bodies, or commit messages.
22+
*/
23+
function sanitizeTaskText(text: string): string {
24+
return text.replace(/<\/?[^>]+>/g, '').trim()
25+
}
26+
1927
export async function runDevLoop(config: DevLoopConfig): Promise<DevLoopResult> {
2028
const { job } = config
29+
// Sanitize task text early so all downstream uses (prompts, PR body, commit messages)
30+
// receive clean text without Telegram formatting delimiters.
31+
job.task = sanitizeTaskText(job.task)
32+
2133
const maxLoops = job.max_loops
2234
const maxBudget = job.max_budget_usd
2335
const maxTurns = config.maxTurnsPerLoop || DEFAULT_MAX_TURNS_PER_LOOP
@@ -195,7 +207,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise<DevLoopResult>
195207
})
196208

197209
const eventType =
198-
lastTestResults.failed === 0 && lastTestResults.total > 0
210+
lastTestResults.failed === 0 && lastTestResults.errors === 0
199211
? 'test_pass'
200212
: 'test_fail'
201213
await emit(supabase, job.id, eventType, loop, {
@@ -230,15 +242,15 @@ export async function runDevLoop(config: DevLoopConfig): Promise<DevLoopResult>
230242

231243
// 5c. Check pass
232244
allTestsPassed =
233-
lastTestResults.failed === 0 && lastTestResults.total > 0
245+
lastTestResults.failed === 0 && lastTestResults.errors === 0
234246
}
235247

236248
// 6. Commit and push
237249
const commitMessage = allTestsPassed
238250
? `feat: ${job.task.slice(0, 60)}`
239251
: `wip: ${job.task.slice(0, 60)} (${lastTestResults.passed}/${lastTestResults.total} tests passing)`
240252

241-
const commitSha = await commitAndPush(workDir, commitMessage)
253+
const commitSha = await commitAndPush(workDir, commitMessage, job.github_token)
242254

243255
// 7. Create PR
244256
let prUrl: string | undefined
@@ -248,6 +260,7 @@ export async function runDevLoop(config: DevLoopConfig): Promise<DevLoopResult>
248260
job.task.slice(0, 70),
249261
buildPrBody(job.task, lastTestResults, loopsCompleted, totalCost),
250262
job.branch,
263+
job.github_token,
251264
)
252265
await emit(supabase, job.id, 'pr_created', undefined, { prUrl })
253266
} catch (err) {
@@ -304,12 +317,19 @@ ${workDir}
304317
- Dependencies are already installed
305318
306319
## Rules
307-
1. Make changes to implement the requested task
308-
2. After making changes, run tests to verify they pass
309-
3. If tests fail, fix the code (not the tests) unless the tests are clearly wrong
310-
4. Keep changes minimal and focused on the task
311-
5. Do not refactor unrelated code
312-
6. Do not add unnecessary dependencies`
320+
1. Make changes to implement the requested task.
321+
2. After making changes, run tests to verify they pass.
322+
3. If tests fail, fix the code (not the tests) unless the tests are clearly wrong.
323+
4. Keep changes minimal and focused ONLY on the files directly required by the task.
324+
5. Do not refactor unrelated code.
325+
6. Do not add unnecessary dependencies.
326+
327+
## Hard constraints — NEVER violate these
328+
- NEVER modify test files unless the task explicitly asks for test changes.
329+
- NEVER modify package manifests (package.json, pyproject.toml, Cargo.toml, go.mod, setup.py, setup.cfg, etc.) unless the task explicitly requires adding or removing a dependency.
330+
- NEVER modify lock files (uv.lock, pnpm-lock.yaml, package-lock.json, yarn.lock, Cargo.lock, poetry.lock, etc.) under any circumstances.
331+
- If the task is about documentation (README, docs/, *.md), ONLY modify documentation files. Do NOT touch source code, tests, or manifests.
332+
- When in doubt about whether a file is in scope, leave it unchanged.`
313333
}
314334

315335
function buildInitialPrompt(

apps/worker/src/github-ops.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export async function cloneRepo(repoUrl: string, workDir: string, token: string,
77
try {
88
const cloneArgs = branch ? ['--branch', branch] : []
99
await git.clone(authedUrl, workDir, cloneArgs)
10+
// Strip credentials from the stored remote URL so they aren't
11+
// accessible via .git/config (e.g. by the Claude subprocess).
12+
const repoGit = simpleGit(workDir)
13+
await repoGit.remote(['set-url', 'origin', repoUrl])
1014
} catch (err) {
1115
const msg = err instanceof Error ? err.message : String(err)
1216
throw new Error(sanitizeGitError(msg, cleanToken))
@@ -31,7 +35,7 @@ export async function createFeatureBranch(
3135
return branchName
3236
}
3337

34-
export async function commitAndPush(workDir: string, message: string): Promise<string> {
38+
export async function commitAndPush(workDir: string, message: string, githubToken?: string): Promise<string> {
3539
const git = simpleGit(workDir)
3640

3741
await git.addConfig('user.email', 'wright@openadaptai.noreply.github.com')
@@ -47,28 +51,65 @@ export async function commitAndPush(workDir: string, message: string): Promise<s
4751

4852
await git.commit(message)
4953

54+
// Re-inject credentials for push (they were stripped from the remote after clone).
55+
const cleanToken = githubToken?.trim()
56+
if (cleanToken) {
57+
const remotes = await git.remote(['-v'])
58+
const originMatch = (remotes || '').match(/origin\s+(https:\/\/[^\s]+)/)
59+
if (originMatch) {
60+
const authedUrl = originMatch[1].replace('https://', `https://x-access-token:${cleanToken}@`)
61+
await git.remote(['set-url', 'origin', authedUrl])
62+
}
63+
}
64+
5065
const branchStatus = await git.branch()
5166
const currentBranch = branchStatus.current
52-
await git.push('origin', currentBranch)
67+
68+
try {
69+
await git.push('origin', currentBranch)
70+
} catch (err) {
71+
const msg = err instanceof Error ? err.message : String(err)
72+
throw new Error(cleanToken ? sanitizeGitError(msg, cleanToken) : msg)
73+
} finally {
74+
// Strip credentials from remote again after push.
75+
if (cleanToken) {
76+
const remotes = await git.remote(['-v'])
77+
const originMatch = (remotes || '').match(/origin\s+(https:\/\/[^\s@]+@)?([^\s]+)/)
78+
if (originMatch) {
79+
const plainUrl = originMatch[0].replace(/x-access-token:[^@]+@/, '')
80+
await git.remote(['set-url', 'origin', plainUrl.replace(/^origin\s+/, '')]).catch(() => {})
81+
}
82+
}
83+
}
5384

5485
const log = await git.log({ maxCount: 1 })
5586
return log.latest?.hash || ''
5687
}
5788

5889
/**
5990
* Create a pull request using the gh CLI.
91+
* Requires `githubToken` so the `gh` CLI can authenticate without `gh auth login`.
6092
*/
6193
export async function createPullRequest(
6294
workDir: string,
6395
title: string,
6496
body: string,
6597
baseBranch: string = 'main',
98+
githubToken?: string,
6699
): Promise<string> {
67100
const { execFileSync } = await import('child_process')
101+
// Minimal env — only what `gh` needs. Avoids leaking SUPABASE_SERVICE_ROLE_KEY etc.
102+
const env: Record<string, string> = {
103+
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin',
104+
HOME: process.env.HOME || '/home/wright',
105+
}
106+
if (githubToken) {
107+
env.GH_TOKEN = githubToken
108+
}
68109
const result = execFileSync(
69110
'gh',
70111
['pr', 'create', '--title', title, '--body', body, '--base', baseBranch],
71-
{ cwd: workDir, encoding: 'utf-8' },
112+
{ cwd: workDir, encoding: 'utf-8', env },
72113
)
73114
return result.trim() // Returns the PR URL
74115
}

0 commit comments

Comments
 (0)