typeclaw 0.37.5 → 0.37.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1168,7 +1168,7 @@ function persistMigratedConfig(cwd: string, json: unknown, applied: readonly Mig
1168
1168
  }
1169
1169
  }
1170
1170
 
1171
- export type ValidateConfigResult = { ok: true } | { ok: false; reason: string }
1171
+ export type ValidateConfigResult = { ok: true; warnings?: string[] } | { ok: false; reason: string }
1172
1172
 
1173
1173
  // Missing file → ok (matches `loadMounts` in src/container/up.ts; `isInitialized`
1174
1174
  // is the dedicated check for "not initialized"). Present but invalid → fail, so
@@ -1200,6 +1200,22 @@ export function validateConfig(cwd: string, options: ValidateConfigOptions = {})
1200
1200
  const parsed = parseConfigJson(raw, { migrate: true, persistTarget: cwd })
1201
1201
  if (!parsed.ok) return parsed
1202
1202
 
1203
+ // Append lines are advisory here — never fatal. The Dockerfile renderer
1204
+ // (renderCustomDockerfileLines) is the enforcement boundary: it STRIPS unsafe
1205
+ // lines so the container still comes up, and a bad line written by the
1206
+ // in-container agent can never brick `typeclaw start`. We surface the same
1207
+ // strip/warn decisions as warnings so the operator sees them pre-build.
1208
+ const warnings: string[] = []
1209
+ const appendLines = parsed.config.docker.file.append
1210
+ for (let i = 0; i < appendLines.length; i++) {
1211
+ const check = validateDockerfileAppendLine(appendLines[i]!)
1212
+ if (!check.ok) {
1213
+ warnings.push(`docker.file.append[${i}] will be stripped on start — ${check.reason}`)
1214
+ continue
1215
+ }
1216
+ if (check.warning) warnings.push(`docker.file.append[${i}] ${check.warning}`)
1217
+ }
1218
+
1203
1219
  if (!options.skipMounts) {
1204
1220
  for (const mount of parsed.config.mounts) {
1205
1221
  const check = validateMount(mount, cwd)
@@ -1207,7 +1223,7 @@ export function validateConfig(cwd: string, options: ValidateConfigOptions = {})
1207
1223
  }
1208
1224
  }
1209
1225
 
1210
- return { ok: true }
1226
+ return warnings.length > 0 ? { ok: true, warnings } : { ok: true }
1211
1227
  }
1212
1228
 
1213
1229
  export type ParseConfigJsonResult = { ok: true; config: Config } | { ok: false; reason: string }
@@ -1253,11 +1269,15 @@ export function parseConfigJson(raw: string, options: ParseConfigJsonOptions = {
1253
1269
  return { ok: true, config: result.data }
1254
1270
  }
1255
1271
 
1256
- // Verifies a mount's host path: exists, is a directory, is readable, and is
1257
- // writable when not declared `readOnly`. Symlinks are followed (statSync's
1258
- // default) so a broken symlink reads as "does not exist". Permission checks
1259
- // are skipped when running as root (uid 0) euidaccess returns success
1260
- // regardless, so the test would be vacuous and inconsistent with non-root.
1272
+ // Verifies a mount's host path: exists, is a regular file or directory, is
1273
+ // readable, and is writable when not declared `readOnly`. Symlinks are
1274
+ // followed (statSync's default) so a broken symlink reads as "does not exist".
1275
+ // File mounts are allowed so credentials and config can be exposed as a single
1276
+ // path (e.g. an SSH private key); sockets, FIFOs, and devices are rejected
1277
+ // because exposing them is an advanced, security-sensitive case we don't take
1278
+ // implicitly. Permission checks are skipped when running as root (uid 0) —
1279
+ // euidaccess returns success regardless, so the test would be vacuous and
1280
+ // inconsistent with non-root.
1261
1281
  export function validateMount(mount: Mount, cwd: string): ValidateConfigResult {
1262
1282
  const resolved = expandMountPath(mount.path, cwd)
1263
1283
  const label = `mount "${mount.name}"`
@@ -1274,8 +1294,8 @@ export function validateMount(mount: Mount, cwd: string): ValidateConfigResult {
1274
1294
  return { ok: false, reason: `${label}: cannot stat ${resolved}: ${detail}` }
1275
1295
  }
1276
1296
 
1277
- if (!stats.isDirectory()) {
1278
- return { ok: false, reason: `${label}: path ${resolved} is not a directory` }
1297
+ if (!stats.isDirectory() && !stats.isFile()) {
1298
+ return { ok: false, reason: `${label}: path ${resolved} is not a file or directory` }
1279
1299
  }
1280
1300
 
1281
1301
  const isRoot = typeof process.getuid === 'function' && process.getuid() === 0
@@ -1301,6 +1321,179 @@ export function validateMount(mount: Mount, cwd: string): ValidateConfigResult {
1301
1321
  return { ok: true }
1302
1322
  }
1303
1323
 
1324
+ // FROM/ENTRYPOINT/CMD/MAINTAINER are intentionally excluded — see the
1325
+ // structural blocks in validateDockerfileAppendLine for why.
1326
+ const ALLOWED_APPEND_INSTRUCTIONS = new Set([
1327
+ 'RUN',
1328
+ 'ENV',
1329
+ 'ARG',
1330
+ 'LABEL',
1331
+ 'COPY',
1332
+ 'ADD',
1333
+ 'USER',
1334
+ 'WORKDIR',
1335
+ 'SHELL',
1336
+ 'EXPOSE',
1337
+ 'VOLUME',
1338
+ 'STOPSIGNAL',
1339
+ 'HEALTHCHECK',
1340
+ 'ONBUILD',
1341
+ ])
1342
+
1343
+ // Decode primitives that, paired with dynamic execution on the same line, form
1344
+ // the "decode an opaque blob and run it" anti-pattern that bricked a real build
1345
+ // (an agent base64-decoded the bash entrypoint shim and fed it to python3
1346
+ // exec). Matching is substring/case-insensitive — these are code tokens the
1347
+ // agent emits, not natural-language, so English literals are correct here (cf.
1348
+ // the protocol-token exception in AGENTS.md).
1349
+ const DECODE_PRIMITIVES = ['base64', 'b64decode', 'atob(', 'unhexlify', '.fromhex(', 'xxd -r']
1350
+
1351
+ // True dynamic-execution sinks — language constructs that run a STRING as code.
1352
+ // Deliberately NOT including interpreter flags like `python3 -c`/`node -e`: a
1353
+ // benign `python3 -c "print(base64.b64encode(...))"` legitimately mentions a
1354
+ // decode primitive without ever executing the decoded bytes. The footgun is
1355
+ // decode + a real exec sink (or decode piped to an interpreter, below).
1356
+ const EXEC_PRIMITIVES = ['exec(', 'eval(', 'new function(', 'function(']
1357
+
1358
+ // Decoded stdout piped straight into an interpreter: `base64 -d ... | sh`,
1359
+ // `... | python3`, etc. The pipe is the execution step here, so it pairs with
1360
+ // DECODE_PRIMITIVES independently of the EXEC_PRIMITIVES sinks above.
1361
+ const DECODE_PIPED_TO_INTERPRETER =
1362
+ /\|\s*(?:sudo\s+)?(?:ba)?sh\b|\|\s*(?:sudo\s+)?python3?\b|\|\s*(?:sudo\s+)?(?:node|perl|ruby)\b/i
1363
+
1364
+ // Risky-but-legitimate operator patterns: piping a remote script straight into
1365
+ // a shell, or ADDing a remote URL. Common enough in real build steps that a
1366
+ // hard block would frustrate power users, dangerous enough to flag.
1367
+ const APPEND_WARN_PATTERNS: Array<{ test: RegExp; note: string }> = [
1368
+ {
1369
+ test: /\b(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:ba)?sh\b/i,
1370
+ note: 'pipes a remote script directly into a shell (curl|bash); verify the source is trusted',
1371
+ },
1372
+ {
1373
+ test: /<\(\s*(?:curl|wget)\b/i,
1374
+ note: 'executes a remote script via process substitution; verify the source is trusted',
1375
+ },
1376
+ {
1377
+ test: /^ADD\s+https?:\/\//i,
1378
+ note: 'ADD of a remote URL fetches an unpinned artifact at build time; prefer a pinned COPY or checksum-verified RUN',
1379
+ },
1380
+ ]
1381
+
1382
+ export type AppendLineCheck =
1383
+ | { ok: true; warning?: string }
1384
+ // `structural` blocks are unconditional (they break Dockerfile generation);
1385
+ // `semantic` blocks are waivable via the host env override.
1386
+ | { ok: false; reason: string; kind: 'structural' | 'semantic' }
1387
+
1388
+ // Pure, side-effect-free validator for ONE docker.file.append entry. The newline
1389
+ // rejection stays in the zod schema (dockerfileLineSchema) so it fires on every
1390
+ // parse including the agent's own config-write guard; this adds the contextual
1391
+ // policy the schema can't express cheaply. Returns the first problem found.
1392
+ export function validateDockerfileAppendLine(line: string): AppendLineCheck {
1393
+ const trimmed = line.trim()
1394
+
1395
+ if (trimmed === '') {
1396
+ return { ok: false, reason: 'is empty or whitespace-only', kind: 'structural' }
1397
+ }
1398
+
1399
+ // A trailing backslash is a line continuation: it would merge the generated
1400
+ // ENTRYPOINT (spliced right after the append block) into this instruction.
1401
+ if (/\\\s*$/.test(line)) {
1402
+ return {
1403
+ ok: false,
1404
+ reason:
1405
+ 'ends with a line-continuation backslash, which would swallow the generated ENTRYPOINT; keep each entry self-contained',
1406
+ kind: 'structural',
1407
+ }
1408
+ }
1409
+
1410
+ // Heredoc syntax spans multiple lines by definition and cannot work in a
1411
+ // single spliced entry — it would consume the following generated lines.
1412
+ if (/<<-?\s*['"]?\w/.test(trimmed)) {
1413
+ return {
1414
+ ok: false,
1415
+ reason: 'uses heredoc syntax (<<EOF), which cannot be expressed as a single Dockerfile line',
1416
+ kind: 'structural',
1417
+ }
1418
+ }
1419
+
1420
+ if (trimmed.startsWith('#')) {
1421
+ // Parser directives (`# syntax=`, `# escape=`) only have meaning at the top
1422
+ // of a Dockerfile; spliced before ENTRYPOINT they are at best inert and at
1423
+ // worst confusing. Plain comments are fine.
1424
+ if (/^#\s*(syntax|escape|check)\s*=/i.test(trimmed)) {
1425
+ return {
1426
+ ok: false,
1427
+ reason: 'is a parser directive (# syntax=/# escape=), which is only valid at the top of a Dockerfile',
1428
+ kind: 'structural',
1429
+ }
1430
+ }
1431
+ return { ok: true }
1432
+ }
1433
+
1434
+ const instruction = trimmed.split(/\s+/, 1)[0]?.toUpperCase() ?? ''
1435
+
1436
+ if (instruction === 'FROM') {
1437
+ return {
1438
+ ok: false,
1439
+ reason: 'starts a new build stage (FROM), discarding everything TypeClaw layered before it',
1440
+ kind: 'structural',
1441
+ }
1442
+ }
1443
+ if (instruction === 'ENTRYPOINT' || instruction === 'CMD') {
1444
+ return {
1445
+ ok: false,
1446
+ reason: `overrides the container ${instruction}, which TypeClaw owns (the entrypoint shim is appended right after this block)`,
1447
+ kind: 'structural',
1448
+ }
1449
+ }
1450
+ if (!ALLOWED_APPEND_INSTRUCTIONS.has(instruction)) {
1451
+ // Reason is intentionally input-free: this string is forwarded verbatim into
1452
+ // operator-facing warnings (classifyDockerfileAppend -> dockerfileWarnings),
1453
+ // and the offending token is user-controlled — echoing it could leak a
1454
+ // secret/token-like first word from a malformed line into start/doctor output.
1455
+ return {
1456
+ ok: false,
1457
+ reason: 'does not begin with a recognized Dockerfile instruction',
1458
+ kind: 'structural',
1459
+ }
1460
+ }
1461
+
1462
+ const lower = trimmed.toLowerCase()
1463
+
1464
+ // The actual incident: mutating TypeClaw's own entrypoint shim. This is never
1465
+ // a supported customization surface — entrypoint changes belong in TypeClaw
1466
+ // source, not in a build-time patch script.
1467
+ if (lower.includes('typeclaw-entrypoint')) {
1468
+ return {
1469
+ ok: false,
1470
+ reason:
1471
+ 'references the TypeClaw-owned entrypoint (typeclaw-entrypoint); patching it from docker.file.append is unsupported and brittle',
1472
+ kind: 'semantic',
1473
+ }
1474
+ }
1475
+
1476
+ // Decode-an-opaque-blob-and-execute-it. A benign decode (encoding output,
1477
+ // writing a file) or a bare `python3 -c "print(...)"` both pass; only decode
1478
+ // PAIRED with a real exec sink — or piped into an interpreter — is blocked.
1479
+ const hasDecode = DECODE_PRIMITIVES.some((p) => lower.includes(p))
1480
+ const hasExec = EXEC_PRIMITIVES.some((p) => lower.includes(p)) || DECODE_PIPED_TO_INTERPRETER.test(lower)
1481
+ if (hasDecode && hasExec) {
1482
+ return {
1483
+ ok: false,
1484
+ reason:
1485
+ 'decodes an opaque payload and executes it (e.g. base64 + exec/eval), an obfuscated-code anti-pattern that has bricked builds',
1486
+ kind: 'semantic',
1487
+ }
1488
+ }
1489
+
1490
+ for (const { test, note } of APPEND_WARN_PATTERNS) {
1491
+ if (test.test(trimmed)) return { ok: true, warning: note }
1492
+ }
1493
+
1494
+ return { ok: true }
1495
+ }
1496
+
1304
1497
  function formatZodError(error: z.ZodError): string {
1305
1498
  return error.issues
1306
1499
  .map((issue) => {
@@ -20,7 +20,7 @@ import {
20
20
  readInstalledTypeclawVersionFromAgent,
21
21
  } from '@/init/auto-upgrade'
22
22
  import { resolveBaseImageVersion } from '@/init/cli-version'
23
- import { buildDockerfile, DOCKERFILE } from '@/init/dockerfile'
23
+ import { buildDockerfile, classifyDockerfileAppend, DOCKERFILE } from '@/init/dockerfile'
24
24
  import { ensureDepsInstalled, type EnsureDepsResult } from '@/init/ensure-deps'
25
25
  import { buildGitignore, GITIGNORE_FILE } from '@/init/gitignore'
26
26
  import { refreshPackageJson } from '@/init/packagejson'
@@ -148,6 +148,10 @@ export type StartResult =
148
148
  // registry. Non-fatal by design: a typo'd or unpublished plugin warns
149
149
  // instead of blocking the launch.
150
150
  skippedPlugins: string[]
151
+ // Non-fatal warnings from docker.file.append: unsafe lines stripped from
152
+ // the generated Dockerfile, plus warn-but-allow lines (curl|bash, remote
153
+ // ADD). Surfaced by the CLI so a stripped line is never a silent no-op.
154
+ dockerfileWarnings: string[]
151
155
  }
152
156
  | { ok: false; reason: string }
153
157
 
@@ -485,6 +489,7 @@ export async function start({
485
489
  alreadyRunning: false,
486
490
  autoUpgrade: upgrade,
487
491
  skippedPlugins: pluginReconcile.skipped,
492
+ dockerfileWarnings: dockerfileRefresh.warnings,
488
493
  }
489
494
  } catch (error) {
490
495
  return { ok: false, reason: error instanceof Error ? error.message : String(error) }
@@ -701,18 +706,24 @@ async function resolvePublishHost(exec: DockerExec): Promise<string> {
701
706
  // the cheapest correct signal: the build context for `docker build` is the
702
707
  // Dockerfile itself, so equal contents definitionally produce an equivalent
703
708
  // image.
704
- export async function refreshDockerfile(cwd: string, opts: { buildKit?: boolean } = {}): Promise<{ changed: boolean }> {
709
+ export async function refreshDockerfile(
710
+ cwd: string,
711
+ opts: { buildKit?: boolean } = {},
712
+ ): Promise<{ changed: boolean; warnings: string[] }> {
705
713
  const cfg = await loadTypeclawConfig(cwd)
706
714
  const next = buildDockerfile(cfg.docker.file, {
707
715
  baseImageVersion: resolveBaseImageVersion(cwd),
708
716
  cjkFontsAuto: hostLocaleIsCjk(),
709
717
  buildKit: opts.buildKit,
710
718
  })
719
+ // Reuse the renderer's classifier so reported warnings match exactly what was
720
+ // stripped/kept in the Dockerfile above (single source of truth).
721
+ const { warnings } = classifyDockerfileAppend(cfg.docker.file.append)
711
722
  const path = join(cwd, DOCKERFILE)
712
723
  const prev = await readFile(path, 'utf8').catch(() => null)
713
- if (prev === next) return { changed: false }
724
+ if (prev === next) return { changed: false, warnings }
714
725
  await writeFile(path, next)
715
- return { changed: true }
726
+ return { changed: true, warnings }
716
727
  }
717
728
 
718
729
  // Builds the agent image with a seamless buildx->legacy fallback. The preferred
@@ -836,6 +847,7 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
836
847
  }
837
848
  const tuiToken = await resolveTuiToken({ cwd, exec })
838
849
  const plan = await planStart({ cwd, hostPort, imageExists: true, forceBuild: false, tuiToken })
850
+ const { warnings: dockerfileWarnings } = classifyDockerfileAppend((await loadTypeclawConfig(cwd)).docker.file.append)
839
851
  return {
840
852
  ok: true,
841
853
  plan,
@@ -847,6 +859,7 @@ async function reportAlreadyRunning(exec: DockerExec, cwd: string, containerName
847
859
  alreadyRunning: true,
848
860
  autoUpgrade: { kind: 'skipped-already-running' },
849
861
  skippedPlugins: [],
862
+ dockerfileWarnings,
850
863
  }
851
864
  }
852
865
 
@@ -1,4 +1,5 @@
1
1
  import type { AgentSession } from '@/agent'
2
+ import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
2
3
  import { promptWithFallback, resolveFallbackChain } from '@/agent/model-fallback'
3
4
  import type { SessionOrigin } from '@/agent/session-origin'
4
5
  import { getConfig } from '@/config'
@@ -235,6 +236,12 @@ async function runPromptOnce(
235
236
  if (created.hooks && turnEvent !== undefined) {
236
237
  await created.hooks.runSessionTurnStart({ ...turnEvent, userPrompt: job.prompt, retrievalContext })
237
238
  }
239
+ // Cron sessions are created fresh per fallback attempt, so the live getter
240
+ // is still the creation-time default here — safe to read without a separate
241
+ // captured field. The test-fake path omits `.session`; skip it then.
242
+ if (created.session !== undefined) {
243
+ applyTurnThinkingLevel(created.session, job.prompt, created.session.thinkingLevel)
244
+ }
238
245
  // Bridge the CronSession wrapper into the AgentSession surface the
239
246
  // fallback helper expects:
240
247
  // prompt → CronSession.prompt (wrapper that calls AgentSession.prompt
@@ -338,16 +345,16 @@ async function runExec(job: ExecJob, cwd: string): Promise<void> {
338
345
  const proc = Bun.spawn({
339
346
  cmd: [cmd, ...args],
340
347
  cwd,
341
- stdout: 'pipe',
348
+ stdout: 'ignore',
342
349
  stderr: 'pipe',
343
350
  env: {
344
351
  ...process.env,
345
352
  TYPECLAW_PARENT_ORIGIN_JSON: JSON.stringify(parentOrigin),
346
353
  },
347
354
  })
348
- const code = await proc.exited
355
+ const stderrText = new Response(proc.stderr).text()
356
+ const [code, stderr] = await Promise.all([proc.exited, stderrText])
349
357
  if (code !== 0) {
350
- const stderr = await new Response(proc.stderr).text()
351
358
  throw new Error(`exec job ${job.id} exited with code ${code}: ${stderr.trim() || 'no stderr'}`)
352
359
  }
353
360
  }
@@ -210,7 +210,19 @@ function configValid(): DoctorCheck {
210
210
  applies: (ctx) => ctx.hasAgentFolder,
211
211
  async run(ctx) {
212
212
  const result = validateConfig(ctx.cwd)
213
- if (result.ok) return { status: 'ok', message: 'typeclaw.json valid; mounts accessible' }
213
+ if (result.ok) {
214
+ if (result.warnings && result.warnings.length > 0) {
215
+ return {
216
+ status: 'warning',
217
+ message: `typeclaw.json valid; ${result.warnings.length} docker.file.append warning(s):\n${result.warnings.join('\n')}`,
218
+ fix: {
219
+ description:
220
+ 'Review the docker.file.append entries above; unsafe lines are stripped from the Dockerfile on start.',
221
+ },
222
+ }
223
+ }
224
+ return { status: 'ok', message: 'typeclaw.json valid; mounts accessible' }
225
+ }
214
226
  return {
215
227
  status: 'error',
216
228
  message: result.reason,
@@ -1,3 +1,4 @@
1
+ import { validateDockerfileAppendLine } from '@/config/config'
1
2
  import type { DockerfileConfig, DockerfileFeatureToggle } from '@/config/config'
2
3
  import {
3
4
  CLAUDE_CREDENTIALS_FILE_NAME,
@@ -358,9 +359,9 @@ set -eu
358
359
  # The persist root lives under /agent/.typeclaw/home/ (bind-mounted
359
360
  # from the agent folder via the -v <cwd>:/agent flag in start.ts).
360
361
  # Namespacing under .typeclaw/ keeps the agent's top-level layout clean and reserves
361
- # a system-owned subtree we can extend later (e.g. ~/.gemini/,
362
- # ~/.config/<tool>/) without colliding with user files. The directory
363
- # is gitignored by buildGitignore() so credentials never enter history.
362
+ # a system-owned subtree we can extend later (e.g. ~/.gemini/) without
363
+ # colliding with user files. The directory is gitignored by buildGitignore()
364
+ # so credentials never enter history.
364
365
  #
365
366
  # Three invariants this function enforces:
366
367
  #
@@ -372,11 +373,11 @@ set -eu
372
373
  # if a previous container life happened to write a real ~/.codex/
373
374
  # dir before this code shipped.
374
375
  #
375
- # 2. We symlink the FILE, not the directory. Codex writes other state
376
- # to ~/.codex/ over time (history.jsonl, log/, config.toml). Linking
377
- # only auth.json keeps the persistence scope tight to credentials;
378
- # history/logs stay ephemeral by design. Future credentials get
379
- # added file-by-file here, not by widening to a directory link.
376
+ # 2. We symlink credential FILES for tools whose config dirs are mostly
377
+ # scratch/history (Codex, Claude). We do not redirect global config
378
+ # locations such as XDG_CONFIG_HOME or ~/.config here because tools like
379
+ # git also read config from those paths; first-party bundles that need
380
+ # persistence should set their own app-specific env vars instead.
380
381
  #
381
382
  # 3. We mkdir -p the target's parent on every boot. /agent is bind-
382
383
  # mounted, so the host-side path may exist or not depending on
@@ -1264,6 +1265,9 @@ ${fromAndHeavyLayers}
1264
1265
 
1265
1266
  ENV NODE_ENV=production
1266
1267
 
1268
+ # Persist first-party GWS config without changing global XDG/git config lookup.
1269
+ ENV GWS_CONFIG_HOME=/agent/workspace/.config/gws
1270
+
1267
1271
  # Keep agent-messenger's fallback config dir inside workspace/ for any future
1268
1272
  # SDK fallback paths. TypeClaw's KakaoTalk adapter does not write there:
1269
1273
  # credentials live in secrets.json#channels.kakaotalk and container writes go
@@ -1697,10 +1701,57 @@ RUN ${aptCacheMount(buildKit)}apt-get update \\
1697
1701
  `
1698
1702
  }
1699
1703
 
1704
+ // Render-time enforcement is the security boundary: this is the sole bottleneck
1705
+ // where docker.file.append entries reach the generated Dockerfile, so unsafe
1706
+ // lines are STRIPPED here (never spliced into the build) rather than blocking
1707
+ // `typeclaw start`. docker.file.append is untrusted input even when the
1708
+ // in-container agent writes it — a bad line (e.g. the decode-and-exec footgun
1709
+ // that bricked a real build) becomes ineffective, not fatal. Validation
1710
+ // elsewhere only warns; this is what guarantees the line never runs.
1700
1711
  function renderCustomDockerfileLines(lines: string[]): string {
1701
- if (lines.length === 0) return ''
1702
- return `# Custom lines from typeclaw.json#docker.file.append.
1703
- ${lines.join('\n')}
1712
+ const { kept, strippedCount } = classifyDockerfileAppend(lines)
1713
+ if (kept.length === 0 && strippedCount === 0) return ''
1714
+ // Durable, payload-free record so a stripped line isn't a silent no-op: the
1715
+ // operator sees the Dockerfile itself note the count (full reasons surface as
1716
+ // startup warnings). Never echo the stripped content — it may carry secrets.
1717
+ const strippedNote =
1718
+ strippedCount > 0
1719
+ ? `# typeclaw stripped ${strippedCount} unsafe docker.file.append line(s); see startup warnings and typeclaw.json.
1720
+ `
1721
+ : ''
1722
+ if (kept.length === 0) return strippedNote ? `${strippedNote}\n` : ''
1723
+ return `${strippedNote}# Custom lines from typeclaw.json#docker.file.append.
1724
+ ${kept.join('\n')}
1704
1725
 
1705
1726
  `
1706
1727
  }
1728
+
1729
+ export type DockerfileAppendClassification = {
1730
+ kept: string[]
1731
+ warnings: string[]
1732
+ strippedCount: number
1733
+ }
1734
+
1735
+ // Single source of truth for "what happens to each docker.file.append line",
1736
+ // reusing the config-layer classifier (validateDockerfileAppendLine). Both the
1737
+ // renderer (enforcement: drops unsafe lines) and refreshDockerfile (reporting:
1738
+ // surfaces warnings to the user) call this so the two never drift. Warn-but-
1739
+ // allow lines (curl|bash, remote ADD) are KEPT but produce a warning; structural
1740
+ // and semantic blocks are stripped with a warning.
1741
+ export function classifyDockerfileAppend(lines: string[]): DockerfileAppendClassification {
1742
+ const kept: string[] = []
1743
+ const warnings: string[] = []
1744
+ let strippedCount = 0
1745
+ for (let i = 0; i < lines.length; i++) {
1746
+ const line = lines[i]!
1747
+ const check = validateDockerfileAppendLine(line)
1748
+ if (!check.ok) {
1749
+ strippedCount++
1750
+ warnings.push(`docker.file.append[${i}] stripped — ${check.reason}`)
1751
+ continue
1752
+ }
1753
+ if (check.warning) warnings.push(`docker.file.append[${i}] ${check.warning}`)
1754
+ kept.push(line)
1755
+ }
1756
+ return { kept, warnings, strippedCount }
1757
+ }
@@ -5,6 +5,7 @@ import {
5
5
  type CreateSessionResult,
6
6
  type SessionOrigin,
7
7
  } from '@/agent'
8
+ import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
8
9
  import type { ChannelRouter } from '@/channels/router'
9
10
  import type { McpManager } from '@/mcp'
10
11
  import type { PermissionService } from '@/permissions'
@@ -431,6 +432,7 @@ export async function runPromptForCommand(args: {
431
432
  retrievalContext.results.length > 0
432
433
  ? `${renderTurnTimeAnchor()}\n\n${args.text}\n\n${retrievalContext.results}`
433
434
  : `${renderTurnTimeAnchor()}\n\n${args.text}`
435
+ applyTurnThinkingLevel(session, args.text, session.thinkingLevel)
434
436
  try {
435
437
  await session.prompt(turnText)
436
438
  } finally {
@@ -8,6 +8,7 @@ import {
8
8
  type CreateSessionOptions,
9
9
  type CreateSessionResult,
10
10
  } from '@/agent'
11
+ import { applyTurnThinkingLevel } from '@/agent/attention-escalation'
11
12
  import { runPluginDoctorChecks, runPluginDoctorFix } from '@/agent/doctor'
12
13
  import type { LiveSessionRegistry } from '@/agent/live-sessions'
13
14
  import type { LiveSubagentRegistry } from '@/agent/live-subagents'
@@ -174,6 +175,11 @@ type QueuedPrompt = {
174
175
 
175
176
  type SessionState = {
176
177
  session: AgentSession
178
+ // The session's creation-time thinking level, captured once. An escalated turn
179
+ // moves `session.thinkingLevel` to `high`, so neither turn-driving path (drain
180
+ // loop, no-stream fallback) can use the live getter as the reset target — both
181
+ // read this captured default instead.
182
+ turnThinkingDefault: AgentSession['thinkingLevel']
177
183
  sessionFileId: string
178
184
  origin: SessionOrigin
179
185
  sessionManager: { getSessionFile: () => string | undefined } | undefined
@@ -518,6 +524,7 @@ export function createServer({
518
524
 
519
525
  const state: SessionState = {
520
526
  session,
527
+ turnThinkingDefault: session.thinkingLevel,
521
528
  sessionFileId,
522
529
  origin,
523
530
  sessionManager,
@@ -782,6 +789,7 @@ export function createServer({
782
789
  retrievalContext.results.length > 0
783
790
  ? `${renderTurnTimeAnchor()}\n\n${msg.text}\n\n${retrievalContext.results}`
784
791
  : `${renderTurnTimeAnchor()}\n\n${msg.text}`
792
+ applyTurnThinkingLevel(state.session, msg.text, state.turnThinkingDefault)
785
793
  await state.session.prompt(turnText)
786
794
  send(ws, doneMessage(state))
787
795
  } catch (err) {
@@ -1086,6 +1094,7 @@ async function drain(
1086
1094
  retrievalContext.results.length > 0
1087
1095
  ? `${renderTurnTimeAnchor()}\n\n${item.text}\n\n${retrievalContext.results}`
1088
1096
  : `${renderTurnTimeAnchor()}\n\n${item.text}`
1097
+ applyTurnThinkingLevel(state.session, item.text, state.turnThinkingDefault)
1089
1098
  await state.session.prompt(turnText)
1090
1099
  send(ws, doneMessage(state))
1091
1100
  } catch (err) {