@lchase/kiln 0.5.0 → 0.6.0

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.
package/README.md CHANGED
@@ -42,6 +42,60 @@ Workflows (`workflows/*.yaml`) and prompts (`prompts/`) are read from the packag
42
42
 
43
43
  Each checkout is a git repo with a commit at `HEAD`. `tick` adds a worktree and leaves uncommitted files in the checkout as they are. The job edits the worktree, on branch `yak/<run-id>`. With the default `deliver: "local"`, Kiln does not push. `deliver: "pr"` pushes that branch and opens a draft pull request. See [Delivery and dependencies](#delivery-and-dependencies).
44
44
 
45
+ ## Sandbox
46
+
47
+ Sandbox mode is off. Set it in the kiln config when agent steps should run inside yak's Docker sandbox instead of on the machine. A workflow block overrides the global one field by field. `mode` omitted means off at that layer. `allowDomains`, when set, replaces the list.
48
+
49
+ ```json
50
+ {
51
+ "sandbox": { "mode": "docker" },
52
+ "workflows": {
53
+ "investigation": { "sandbox": { "mode": "off" } }
54
+ }
55
+ }
56
+ ```
57
+
58
+ `image` overrides `yak-agent-sandbox:latest`. Leave it unset to use that tag.
59
+
60
+ With mode `docker`, tick injects `sandbox: docker` and `allowedDomains` under every agent step in the rendered workflow. The default allowlist is exact hostnames, matched by the proxy as `dstdomain` (a subdomain is not implied):
61
+
62
+ - `api.anthropic.com`
63
+ - `registry.npmjs.org`
64
+ - `github.com`
65
+ - `api.github.com`
66
+ - `codeload.github.com`
67
+ - `objects.githubusercontent.com`
68
+ - `raw.githubusercontent.com`
69
+
70
+ That is the Anthropic API, the npm registry, and the GitHub hosts a fetch or install dials. It is not the open internet.
71
+
72
+ | Step | Where it runs |
73
+ | --- | --- |
74
+ | feature: spec, build, security, ux, ui, code, approver | Docker sandbox |
75
+ | investigation: explore | Docker sandbox |
76
+ | fix: build | Docker sandbox. A pull-request fix uses the same allowlist. |
77
+ | feature: verify | Host. It runs `KILN_TEST` through Kiln's truncate wrapper. Yak's command sandbox does not forward that environment and uses `--network none`. |
78
+ | approve gates | Host. A person answers them. |
79
+ | feature: land, fix: land | Host. `git commit` uses the host git identity. Publishing the fix branch is the kiln process. |
80
+ | Pull request open, `git push`, `gh pr view`, feedback reads | Host kiln process. They are not workflow steps. |
81
+
82
+ `kiln sandbox` prints the resolved mode, the allowlist, and anything still missing. `kiln sandbox build` builds the images yak ships but does not publish, from the tag that matches the installed `@lchase/yak`:
83
+
84
+ ```bash
85
+ docker build -t yak-agent-sandbox:latest https://github.com/lchase/yak.git#yak-v0.7.1:docker/agent-sandbox
86
+ docker build -t yak-agent-sandbox-proxy:latest https://github.com/lchase/yak.git#yak-v0.7.1:docker/agent-proxy
87
+ ```
88
+
89
+ The agent image is `node:22-slim` plus the Claude CLI at `/app/claude-cli`. It has no git. `kiln sandbox build` adds `git` and `ca-certificates` and tags that result `yak-agent-sandbox:latest` as well. The proxy image is `yak-agent-sandbox-proxy:latest` (squid on port 3128). Yak mounts the worktree at `/workspace` and puts the container on an `--internal` network whose only egress is that proxy.
90
+
91
+ A linked worktree's `.git` file points at the checkout's common git dir, which is outside that mount. `git` inside the container cannot see the repository until yak mounts that dir too. Land stays on the host, where the worktree link works.
92
+
93
+ On macOS, Claude subscription auth lives in the keychain. Yak forwards only `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` into the container, and never a `~/.claude` directory. Before the first sandboxed launch, run `claude setup-token` and export `CLAUDE_CODE_OAUTH_TOKEN` in the environment that `kiln tick` or `kiln serve` inherits. Kiln checks that the variable is set. It does not print or write the value. An API key in `ANTHROPIC_API_KEY` works the same way.
94
+
95
+ `kiln tick` and `kiln serve` name what is missing when sandbox mode is on: the docker binary, the docker daemon, each image, the credential, and a yak build that actually reads `allowedDomains` from the workflow. Installed `@lchase/yak` 0.7.1 drops `sandbox`, `image`, and `allowedDomains` while loading YAML, and opens the proxy with only `api.anthropic.com`. Kiln refuses to launch in that case, because the job would run on the host with the open internet. The job stays failed until `kiln retry`, after that piece is fixed. The yak change that copies those fields and passes `allowedDomains` into `createAgentSandboxNetwork` has to be in the installed package first.
96
+
97
+ A step that fails with yak reason `sandbox-error` goes to Needs human. The card shows the failure detail. Kiln does not schedule an automatic retry for it. The card of a sandboxed job shows `sandbox · docker` in the same monospace metadata as the rest of the board.
98
+
45
99
  ## Register repos
46
100
 
47
101
  ```bash
package/dist/attention.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { INVESTIGATION_WORKFLOW } from "./findings.js";
3
+ import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
3
4
  /**
4
5
  * Attention order for `kiln serve`. Built from the same derived jobs as
5
6
  * `kiln status`. The console renders this and does not classify jobs again.
@@ -103,9 +104,11 @@ export function attentionReason(job, tail = errorTail(job)) {
103
104
  return question ? `Gate: ${oneLine(question)}` : "Waiting on a person";
104
105
  }
105
106
  case "dead":
106
- return "Run died before it finished";
107
+ return job.sandboxError ? `Sandbox: ${oneLine(job.sandboxError)}` : "Run died before it finished";
107
108
  case "failed": {
108
- const why = firstLine(job.launchError) || firstLine(job.deliveryError) || firstLine(job.failureDetail) || firstLine(tail) || "Run failed";
109
+ if (job.sandboxError)
110
+ return `Sandbox: ${oneLine(job.sandboxError)}`;
111
+ const why = failureLine(job.launchError) || firstLine(job.deliveryError) || firstLine(job.failureDetail) || firstLine(tail) || "Run failed";
109
112
  return `Failed: ${oneLine(why)}`;
110
113
  }
111
114
  case "in-review":
@@ -228,6 +231,7 @@ function attentionRevision(needsYou, inFlight, doneRecently, graph) {
228
231
  item.job.lastFailureReason ?? "",
229
232
  item.job.nextRetryAt ?? "",
230
233
  feedbackStamp(item.job),
234
+ item.job.sandboxed ? "sandbox" : "",
231
235
  ]),
232
236
  inFlight: inFlight.map((item) => [
233
237
  item.job.jobId,
@@ -238,6 +242,7 @@ function attentionRevision(needsYou, inFlight, doneRecently, graph) {
238
242
  item.job.lastFailureReason ?? "",
239
243
  item.job.nextRetryAt ?? "",
240
244
  feedbackStamp(item.job),
245
+ item.job.sandboxed ? "sandbox" : "",
241
246
  ]),
242
247
  doneRecently: doneRecently.map((item) => [item.job.jobId, item.job.state]),
243
248
  edges: graph.edges.map((edge) => [edge.from, edge.to, edge.stack]),
@@ -262,3 +267,14 @@ function firstLine(text) {
262
267
  const line = text.split("\n").find((candidate) => candidate.trim().length > 0);
263
268
  return line?.trim();
264
269
  }
270
+ /** The preflight header names the policy. The card should name the missing piece. */
271
+ function failureLine(text) {
272
+ const line = firstLine(text);
273
+ if (!line?.startsWith(SANDBOX_PREFLIGHT_PREFIX))
274
+ return line;
275
+ const bullet = text
276
+ ?.split("\n")
277
+ .map((candidate) => candidate.trim())
278
+ .find((candidate) => candidate.startsWith("- "));
279
+ return bullet ? bullet.slice(2) : line;
280
+ }
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { addRepo, renderRepoList, resolveKilnConfig } from "./config.js";
3
+ import { renderSandboxStatus, runSandboxBuild } from "./sandbox.js";
3
4
  import { answerJob, retryJob, runStatus, runTick } from "./driver.js";
4
5
  import { startServe } from "./serve.js";
5
6
  import { addFromFile } from "./intake/file.js";
@@ -18,6 +19,7 @@ function usage() {
18
19
  " kiln approve <jobId> [--config <file>]",
19
20
  " kiln reject <jobId> [--config <file>]",
20
21
  " kiln retry <jobId> [--config <file>]",
22
+ " kiln sandbox [build] [--config <file>]",
21
23
  " kiln serve [--port 4100] [--tick <seconds>|--no-tick] [--config <file>]",
22
24
  "",
23
25
  "repo add registers a checkout. A relative path is stored absolute, resolved",
@@ -69,6 +71,11 @@ function usage() {
69
71
  "Empty stages collapse. Approve, reject, and retry call the same",
70
72
  "functions as the CLI. POST from another origin is rejected. A tick",
71
73
  "runs every 30 seconds unless --no-tick is set, using the tick lock.",
74
+ "",
75
+ "sandbox prints the resolved mode, allowlist, and anything missing before a",
76
+ "sandboxed run can start. sandbox build builds the yak agent and proxy images",
77
+ "and installs git into the agent image. Sandbox mode stays off until the kiln",
78
+ "config sets it. A run that fails with yak reason sandbox-error is not retried.",
72
79
  ].join("\n"));
73
80
  process.exit(2);
74
81
  }
@@ -315,6 +322,21 @@ function importCommand(argv) {
315
322
  if (result.warnings.length > 0)
316
323
  process.stderr.write(`${result.warnings.join("\n")}\n`);
317
324
  }
325
+ function sandboxCommand(argv) {
326
+ const build = argv[0] === "build";
327
+ const flags = parseFlags(build ? argv.slice(1) : argv);
328
+ rejectServeFlags(flags);
329
+ if (flags.positionals.length !== 0 || flags.test || hasIntakeFlags(flags) || flags.deliver || flags.base || flags.stack) {
330
+ usage();
331
+ }
332
+ if (build) {
333
+ runSandboxBuild();
334
+ process.stdout.write("built yak-agent-sandbox:latest and yak-agent-sandbox-proxy:latest\n");
335
+ return;
336
+ }
337
+ const config = resolveKilnConfig({ configPath: flags.configPath });
338
+ process.stdout.write(renderSandboxStatus(config));
339
+ }
318
340
  function tickCommand(argv) {
319
341
  const flags = parseFlags(argv);
320
342
  rejectServeFlags(flags);
@@ -422,6 +444,10 @@ async function main() {
422
444
  retryCommand(argv.slice(1));
423
445
  return;
424
446
  }
447
+ if (command === "sandbox") {
448
+ sandboxCommand(argv.slice(1));
449
+ return;
450
+ }
425
451
  if (command === "serve") {
426
452
  await serveCommand(argv.slice(1));
427
453
  return;
package/dist/config.js CHANGED
@@ -49,6 +49,8 @@ export function loadKilnConfig(configPath, options = {}) {
49
49
  maxAttempts: parsed.data.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
50
50
  maxFixRounds: parsed.data.maxFixRounds ?? DEFAULT_MAX_FIX_ROUNDS,
51
51
  adapter: parsed.data.adapter ?? "claude-code",
52
+ sandbox: parsed.data.sandbox ?? {},
53
+ workflowSandbox: workflowSandboxFrom(parsed.data.workflows),
52
54
  ...(jira ? { jira } : {}),
53
55
  };
54
56
  }
@@ -235,6 +237,16 @@ function pathToStore(userPath, cwd, home) {
235
237
  return userPath;
236
238
  return resolve(cwd, userPath);
237
239
  }
240
+ function workflowSandboxFrom(workflows) {
241
+ if (!workflows)
242
+ return {};
243
+ const out = {};
244
+ for (const [name, entry] of Object.entries(workflows)) {
245
+ if (entry.sandbox)
246
+ out[name] = entry.sandbox;
247
+ }
248
+ return out;
249
+ }
238
250
  function jiraFromFile(jira) {
239
251
  if (!jira)
240
252
  return undefined;
package/dist/driver.js CHANGED
@@ -11,7 +11,8 @@ import { readJobSpec, resolveJobSpec } from "./jobs.js";
11
11
  import { jobBasename, packageRoot } from "./paths.js";
12
12
  import { decide, isLanded, jobState, } from "./reconcile.js";
13
13
  import { fileMtimeMs, isPidAlive, readAttemptLedger, recoverRuns, resetAttemptLedger, } from "./reliability.js";
14
- import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, } from "./report.js";
14
+ import { gateAnswerCommand, renderStatus, renderTick, statusView, yakCliPath, sandboxFailureDetail, } from "./report.js";
15
+ import { AGENT_SANDBOX_IMAGE, injectAgentSandbox, jobIsSandboxed, probeSandboxEnvironment, PROXY_SANDBOX_IMAGE, requiredSandboxImages, resolveSandbox, sandboxIsEnabled, sandboxPreflight, sandboxPreflightFor, } from "./sandbox.js";
15
16
  import { JobSchema } from "./schemas.js";
16
17
  import { leafStepIds, materializeWorkflow, workflowInputs, workflowNeedsTestCommand, workflowStepPlan } from "./workflow.js";
17
18
  const TAG_PREFIX = "kiln:";
@@ -39,13 +40,18 @@ export function runTick(config, host = defaultHost(), options = {}) {
39
40
  const now = options.now ?? new Date();
40
41
  return withTickLock(config.kilnDir, () => {
41
42
  const notes = recoverRuns(config, observe(config, host).observation.jobs, { startup, now });
43
+ const facts = sandboxIsEnabled(config)
44
+ ? (options.sandboxFacts ?? probeSandboxEnvironment(process.env, requiredSandboxImages(config)))
45
+ : undefined;
42
46
  const midway = observe(config, host);
43
47
  bindOpenFixRuns(config, midway);
44
48
  const published = publishReadyFixes(config, host, midway);
45
49
  const world = observe(config, host);
46
50
  const feedbackNotes = watchPullRequests(config, world, host);
47
51
  const actions = decide(world.observation, now);
48
- return apply(config, world, actions, host, notes, now, [...published, ...feedbackNotes]);
52
+ const body = apply(config, world, actions, host, notes, now, [...published, ...feedbackNotes], facts);
53
+ const problem = facts ? sandboxPreflight(config, facts) : undefined;
54
+ return problem ? `${problem}\n${body}` : body;
49
55
  });
50
56
  }
51
57
  /** Startup pass shared by `kiln tick` (via `runTick`) and `kiln serve`. Idempotent. */
@@ -247,6 +253,7 @@ function observe(config, host) {
247
253
  ...(exhausted ? { retriesExhausted: true } : {}),
248
254
  ...(picked.retired ? { runRetired: true } : {}),
249
255
  ...(ledgerResult === "corrupt" ? { ledgerError: "attempt ledger is unreadable" } : {}),
256
+ ...(entry.job ? { sandboxed: jobIsSandboxed(config, entry.job.id, entry.job.workflow) } : {}),
250
257
  ...(feedback?.rounds.length ? { feedbackRounds: feedback.rounds } : {}),
251
258
  ...(feedback?.exhausted ? { feedbackExhausted: true } : {}),
252
259
  ...(feedback?.ambiguous ? { feedbackAmbiguous: true } : {}),
@@ -268,7 +275,7 @@ function observe(config, host) {
268
275
  jobs: records,
269
276
  };
270
277
  }
271
- function apply(config, world, actions, host, notes = [], now = new Date(), feedbackNotes = []) {
278
+ function apply(config, world, actions, host, notes = [], now = new Date(), feedbackNotes = [], sandboxFacts) {
272
279
  const launches = [];
273
280
  const delivers = [];
274
281
  const findings = [];
@@ -297,7 +304,12 @@ function apply(config, world, actions, host, notes = [], now = new Date(), feedb
297
304
  if (!job) {
298
305
  throw new Error(`Tick planned a fix for ${action.jobId}, which is not in the observed jobs.`);
299
306
  }
300
- const report = startFix(config, world, job, host, now);
307
+ const blocked = sandboxBlock(config, "fix", sandboxFacts);
308
+ if (blocked) {
309
+ feedbackNotes.push(`fix ${action.jobId} not started\n${blocked}`);
310
+ continue;
311
+ }
312
+ const report = startFix(config, world, job, host, now, sandboxFacts);
301
313
  launches.push({ ...report, kind: "fix" });
302
314
  continue;
303
315
  }
@@ -305,7 +317,7 @@ function apply(config, world, actions, host, notes = [], now = new Date(), feedb
305
317
  if (!job) {
306
318
  throw new Error(`Tick planned a launch for ${action.jobId}, which is not in the observed jobs.`);
307
319
  }
308
- const report = launchJob(config, world, job, host);
320
+ const report = launchJob(config, world, job, host, sandboxFacts);
309
321
  launches.push(action.type === "retry" ? { ...report, retry: true } : report);
310
322
  }
311
323
  return renderTick(world.observation, actions, launches, delivers, findings, notes, feedbackNotes);
@@ -620,7 +632,7 @@ function markFeedback(kilnDir, jobId, reason) {
620
632
  * The round is written only after that commit resolves, so a missing sha
621
633
  * retries on the next tick without consuming a round.
622
634
  */
623
- function startFix(config, world, job, host, now) {
635
+ function startFix(config, world, job, host, now, sandboxFacts) {
624
636
  const observed = world.observation.jobs.find((candidate) => candidate.jobId === job.id);
625
637
  const plan = observed?.feedbackPlan;
626
638
  const repo = config.repos[job.repo];
@@ -661,11 +673,11 @@ function startFix(config, world, job, host, now) {
661
673
  writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
662
674
  return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
663
675
  }
664
- const report = launchFixRun(config, job, host, repo.path, headSha, specText);
676
+ const report = launchFixRun(config, job, host, repo.path, headSha, specText, sandboxFacts);
665
677
  return { ...report, output: `round ${round}\n${report.output}` };
666
678
  }
667
679
  /** Relaunch the open fix round. Does not append a round. Failures become a dead launch so retry can see them. */
668
- function launchOpenFix(config, world, job, host, checkout, open) {
680
+ function launchOpenFix(config, world, job, host, checkout, open, sandboxFacts) {
669
681
  let specText;
670
682
  let headSha;
671
683
  try {
@@ -682,7 +694,7 @@ function launchOpenFix(config, world, job, host, checkout, open) {
682
694
  writeBreadcrumb(breadcrumbPath(config.kilnDir, job.id), { jobId: job.id, pid: 0, logPath });
683
695
  return { jobId: job.id, repo: job.repo, kind: "fix", output: message };
684
696
  }
685
- return launchFixRun(config, job, host, checkout, headSha, specText);
697
+ return launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts);
686
698
  }
687
699
  function resolveCommit(host, checkout, sha) {
688
700
  try {
@@ -694,12 +706,15 @@ function resolveCommit(host, checkout, sha) {
694
706
  }
695
707
  }
696
708
  /** `workflows/fix.yaml` on `headSha`. Commits on `yak/<run-id>`. Does not push. */
697
- function launchFixRun(config, job, host, checkout, headSha, specText) {
709
+ function launchFixRun(config, job, host, checkout, headSha, specText, sandboxFacts) {
698
710
  const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", "fix.yaml"), "utf8");
699
711
  const workflow = materializeWorkflow(config.kilnDir, job.id, "fix");
700
712
  const repo = config.repos[job.repo];
701
713
  const logPath = join(config.kilnDir, "logs", `${jobBasename(job.id)}.log`);
702
714
  mkdirSync(dirname(logPath), { recursive: true });
715
+ const blocked = commitAgentSandbox(config, job.id, "fix", workflow, logPath, sandboxFacts);
716
+ if (blocked)
717
+ return { jobId: job.id, repo: job.repo, kind: "fix", output: blocked };
703
718
  ensureCheckoutExcludes(checkout);
704
719
  const input = workflowInputs(sourceWorkflow, {
705
720
  title: job.title,
@@ -724,6 +739,34 @@ function launchFixRun(config, job, host, checkout, headSha, specText) {
724
739
  logPath,
725
740
  });
726
741
  }
742
+ /**
743
+ * Names why a sandboxed workflow cannot launch. Does not write a round or a
744
+ * breadcrumb. `undefined` when this workflow stays on the host or the checks pass.
745
+ */
746
+ function sandboxBlock(config, workflowName, sandboxFacts) {
747
+ const sandbox = resolveSandbox(config.sandbox, config.workflowSandbox[workflowName]);
748
+ if (sandbox.mode !== "docker")
749
+ return undefined;
750
+ const checked = sandboxFacts ??
751
+ probeSandboxEnvironment(process.env, [PROXY_SANDBOX_IMAGE, sandbox.image ?? AGENT_SANDBOX_IMAGE]);
752
+ return sandboxPreflightFor(sandbox, config.adapter, checked);
753
+ }
754
+ /**
755
+ * Inject `sandbox: docker` into a materialized workflow and refuse the launch
756
+ * when preflight fails. The refusal is a launch error with no yak process.
757
+ */
758
+ function commitAgentSandbox(config, jobId, workflowName, workflowPath, logPath, sandboxFacts) {
759
+ const sandbox = resolveSandbox(config.sandbox, config.workflowSandbox[workflowName]);
760
+ if (sandbox.mode !== "docker")
761
+ return undefined;
762
+ writeFileSync(workflowPath, injectAgentSandbox(readFileSync(workflowPath, "utf8"), sandbox));
763
+ const problem = sandboxBlock(config, workflowName, sandboxFacts);
764
+ if (!problem)
765
+ return undefined;
766
+ writeLaunchError(config.kilnDir, jobId, problem);
767
+ writeFileSync(logPath, `${problem}\n`);
768
+ return problem;
769
+ }
727
770
  function agentArgs(config, job, workflow, headSha, input) {
728
771
  const args = [
729
772
  yakCliPath(),
@@ -783,7 +826,7 @@ function spawnAgent(host, spec) {
783
826
  child.unref();
784
827
  return { pid: child.pid ?? 0 };
785
828
  }
786
- function launchJob(config, world, job, host) {
829
+ function launchJob(config, world, job, host, sandboxFacts) {
787
830
  const repo = config.repos[job.repo];
788
831
  if (!repo) {
789
832
  throw new Error(`Repo ${job.repo} is not registered. Run kiln repo add ${job.repo} <path>.`);
@@ -791,7 +834,7 @@ function launchJob(config, world, job, host) {
791
834
  const feedback = readFeedbackLedger(config.kilnDir, job.id);
792
835
  const open = feedback && feedback !== "corrupt" ? openFeedbackRound(feedback) : undefined;
793
836
  if (open)
794
- return launchOpenFix(config, world, job, host, repo.path, open);
837
+ return launchOpenFix(config, world, job, host, repo.path, open, sandboxFacts);
795
838
  const sourceWorkflow = readFileSync(join(packageRoot(), "workflows", `${job.workflow}.yaml`), "utf8");
796
839
  const testCommand = repo.test;
797
840
  if (workflowNeedsTestCommand(sourceWorkflow) && !testCommand?.trim()) {
@@ -805,6 +848,9 @@ function launchJob(config, world, job, host) {
805
848
  let specText;
806
849
  try {
807
850
  clearLaunchError(config.kilnDir, job.id);
851
+ const blocked = commitAgentSandbox(config, job.id, job.workflow, workflow, logPath, sandboxFacts);
852
+ if (blocked)
853
+ return { jobId: job.id, repo: job.repo, output: blocked };
808
854
  assertStackTarget(world, job);
809
855
  baseSha = resolveLaunchBase({
810
856
  checkout: repo.path,
@@ -887,6 +933,7 @@ function scanRuns(config) {
887
933
  const finished = [...events].reverse().find((event) => event.t === "run.finished");
888
934
  const stepPlan = readRunStepPlan(runDir);
889
935
  const stepEvents = stepEventsFrom(events);
936
+ const sandboxError = sandboxFailureDetail(stepEvents);
890
937
  const pid = livePid(events);
891
938
  const activityMs = eventActivityMs(events) ?? fileMtimeMs(join(runDir, "journal.jsonl"));
892
939
  const finishedReason = finished && typeof finished.reason === "string" ? finished.reason : undefined;
@@ -904,6 +951,7 @@ function scanRuns(config) {
904
951
  ...(pid !== undefined ? { pid } : {}),
905
952
  ...(activityMs !== undefined ? { activityMs } : {}),
906
953
  ...(finishedReason ? { finishedReason } : {}),
954
+ ...(sandboxError ? { sandboxError } : {}),
907
955
  };
908
956
  const list = byTag.get(started.tag) ?? [];
909
957
  list.push(run);
@@ -919,11 +967,12 @@ function stepEventsFrom(events) {
919
967
  continue;
920
968
  if (!event.stepId)
921
969
  continue;
922
- const detail = event.t === "step.failed" ? formatFailure(event.failure) : undefined;
970
+ const failure = event.t === "step.failed" ? formatFailure(event.failure) : undefined;
923
971
  found.push({
924
972
  t: event.t,
925
973
  stepId: event.stepId,
926
- ...(detail ? { detail } : {}),
974
+ ...(failure?.detail ? { detail: failure.detail } : {}),
975
+ ...(failure?.reason ? { failureReason: failure.reason } : {}),
927
976
  });
928
977
  }
929
978
  return found;
@@ -934,7 +983,12 @@ function formatFailure(failure) {
934
983
  const detail = typeof failure.detail === "string" ? failure.detail.trim() : "";
935
984
  const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
936
985
  const text = detail && reason ? `${detail} (${reason})` : detail || reason;
937
- return text || undefined;
986
+ if (!text && !reason)
987
+ return undefined;
988
+ return {
989
+ ...(text ? { detail: text } : {}),
990
+ ...(reason ? { reason } : {}),
991
+ };
938
992
  }
939
993
  function readRunStepPlan(runDir) {
940
994
  const file = join(runDir, "workflow.json");
@@ -90,6 +90,10 @@ export function recoverRuns(config, jobs, options) {
90
90
  const existing = readAttemptLedger(config.kilnDir, job.jobId);
91
91
  if (existing === "corrupt")
92
92
  continue;
93
+ if (job.run?.sandboxError && job.run.status !== "alive") {
94
+ suppressAutomaticRetry(config.kilnDir, job.jobId);
95
+ continue;
96
+ }
93
97
  const pending = pendingFailure(job, config, options);
94
98
  if (!pending)
95
99
  continue;
@@ -113,6 +117,18 @@ export function recoverRuns(config, jobs, options) {
113
117
  }
114
118
  return notes;
115
119
  }
120
+ /** Drop a scheduled retry so a sandbox failure stays in Needs human. */
121
+ function suppressAutomaticRetry(kilnDir, jobId) {
122
+ const existing = readAttemptLedger(kilnDir, jobId);
123
+ if (!existing || existing === "corrupt" || !existing.nextRetryAt)
124
+ return;
125
+ const written = {
126
+ jobId: existing.jobId,
127
+ attempts: existing.attempts,
128
+ ...(existing.ignoredRunIds && existing.ignoredRunIds.length > 0 ? { ignoredRunIds: existing.ignoredRunIds } : {}),
129
+ };
130
+ writeLedger(kilnDir, written);
131
+ }
116
132
  function pendingFailure(job, config, options) {
117
133
  if (job.run?.gateDecision === "reject")
118
134
  return undefined;
package/dist/report.js CHANGED
@@ -77,6 +77,27 @@ export function latestStepFailure(events) {
77
77
  }
78
78
  return undefined;
79
79
  }
80
+ /**
81
+ * Detail of the latest unresolved step whose yak reason is `sandbox-error`.
82
+ * The `(sandbox-error)` suffix formatFailure adds is stripped.
83
+ */
84
+ export function sandboxFailureDetail(events) {
85
+ const settled = new Set();
86
+ for (let index = events.length - 1; index >= 0; index -= 1) {
87
+ const event = events[index];
88
+ if (!event)
89
+ continue;
90
+ if (event.t === "step.started" || event.t === "step.completed") {
91
+ settled.add(event.stepId);
92
+ continue;
93
+ }
94
+ if (event.t !== "step.failed" || settled.has(event.stepId) || event.failureReason !== "sandbox-error")
95
+ continue;
96
+ const text = (event.detail ?? "").replace(/\s*\(sandbox-error\)\s*$/, "").trim();
97
+ return text || "The sandbox could not run this step.";
98
+ }
99
+ return undefined;
100
+ }
80
101
  /**
81
102
  * Derived state for every job, or for one id.
82
103
  * Throws `No job <id>` when that id is not on disk, same as `kiln status`.
@@ -124,6 +145,11 @@ export function statusDetails(job) {
124
145
  lines.push(`log ${job.logPath}`);
125
146
  if (job.state === "failed" && job.launchError)
126
147
  lines.push(job.launchError);
148
+ if (job.sandboxError && (job.state === "failed" || job.state === "dead" || job.state === "needs-human")) {
149
+ lines.push(`sandbox ${job.sandboxError}`);
150
+ }
151
+ if (job.sandboxed)
152
+ lines.push("sandbox docker");
127
153
  if (job.ledgerError)
128
154
  lines.push(job.ledgerError);
129
155
  if (job.feedbackLedgerError)
@@ -257,6 +283,8 @@ function toStatusJob(job, observation) {
257
283
  ...(job.logTail ? { logTail: job.logTail } : {}),
258
284
  ...(job.diffSummary ? { diffSummary: job.diffSummary } : {}),
259
285
  ...(failureDetail ? { failureDetail } : {}),
286
+ ...(job.run?.sandboxError ? { sandboxError: job.run.sandboxError } : {}),
287
+ ...(job.sandboxed ? { sandboxed: true } : {}),
260
288
  ...boardFields(job),
261
289
  };
262
290
  }
@@ -0,0 +1,363 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { jobBasename } from "./paths.js";
7
+ /**
8
+ * Opt-in Docker sandbox for Kiln agent steps.
9
+ *
10
+ * Off unless kiln config sets `sandbox.mode` to `docker` (a per-workflow
11
+ * block can override that). Kiln injects `sandbox: docker` under each
12
+ * agent step. verify, the approve gate, and land stay on the host: yak's
13
+ * command sandbox does not forward `KILN_TEST` or the truncate wrapper,
14
+ * and land commits with the host git identity. Pull requests and
15
+ * `gh pr view` run in the kiln process.
16
+ *
17
+ * @lchase/yak 0.7.1 drops `sandbox`, `image`, and `allowedDomains` while
18
+ * loading workflow YAML, and calls `createAgentSandboxNetwork` with only
19
+ * `api.anthropic.com`. Kiln refuses to launch while that is still true,
20
+ * because the job would run on the host.
21
+ */
22
+ export const AGENT_SANDBOX_IMAGE = "yak-agent-sandbox:latest";
23
+ export const PROXY_SANDBOX_IMAGE = "yak-agent-sandbox-proxy:latest";
24
+ export const CONTAINER_CLI_PATH = "/app/claude-cli";
25
+ export const CONTAINER_WORKDIR = "/workspace";
26
+ /**
27
+ * Hosts a coding agent needs on a typical Node repo, and nothing wider.
28
+ * `api.anthropic.com` is the model API. The rest are npm and the GitHub
29
+ * hosts a fetch or an install actually dials. Exact hostnames only: the
30
+ * proxy matches `dstdomain` without a leading dot, so a subdomain is not
31
+ * implied.
32
+ */
33
+ export const DEFAULT_SANDBOX_DOMAINS = [
34
+ "api.anthropic.com",
35
+ "registry.npmjs.org",
36
+ "github.com",
37
+ "api.github.com",
38
+ "codeload.github.com",
39
+ "objects.githubusercontent.com",
40
+ "raw.githubusercontent.com",
41
+ ];
42
+ /** Printed at the start of a tick when sandbox mode is on and a check failed. */
43
+ export const SANDBOX_PREFLIGHT_PREFIX = "Sandbox is on, and a sandboxed run cannot start:";
44
+ /** Present in a yak build that copies `allowedDomains` off the agent step. */
45
+ export const YAK_ALLOWLIST_MARKER = "raw.agent.allowedDomains";
46
+ /**
47
+ * Workflow override wins per field. An omitted field inherits the global
48
+ * setting, then the built-in default. `allowDomains`, when set, replaces
49
+ * the list rather than extending it, so a tighter workflow cannot keep a
50
+ * wider global host by accident.
51
+ */
52
+ export function resolveSandbox(global, workflow) {
53
+ const mode = workflow?.mode ?? global?.mode ?? "off";
54
+ const image = workflow?.image ?? global?.image;
55
+ const allowDomains = workflow?.allowDomains ?? global?.allowDomains ?? DEFAULT_SANDBOX_DOMAINS;
56
+ return {
57
+ mode,
58
+ allowDomains: [...allowDomains],
59
+ ...(image ? { image } : {}),
60
+ };
61
+ }
62
+ /** Resolved sandboxes that would actually containerize a packaged or configured workflow. */
63
+ export function sandboxTargets(config) {
64
+ const names = new Set(["feature", "investigation", "fix", ...Object.keys(config.workflowSandbox)]);
65
+ const targets = [];
66
+ for (const name of names) {
67
+ const resolved = resolveSandbox(config.sandbox, config.workflowSandbox[name]);
68
+ if (resolved.mode === "docker")
69
+ targets.push(resolved);
70
+ }
71
+ return targets;
72
+ }
73
+ export function sandboxIsEnabled(config) {
74
+ return sandboxTargets(config).length > 0;
75
+ }
76
+ export function requiredSandboxImages(config) {
77
+ const targets = sandboxTargets(config);
78
+ if (targets.length === 0)
79
+ return [];
80
+ const images = new Set([PROXY_SANDBOX_IMAGE]);
81
+ for (const target of targets)
82
+ images.add(target.image ?? AGENT_SANDBOX_IMAGE);
83
+ return [...images];
84
+ }
85
+ /**
86
+ * Insert `sandbox: docker` as the first keys of every `agent:` block.
87
+ * Command steps, gates, and comments are left alone. A block that already
88
+ * has `sandbox:` is not edited again.
89
+ */
90
+ export function injectAgentSandbox(yaml, sandbox) {
91
+ if (sandbox.mode !== "docker")
92
+ return yaml;
93
+ const lines = yaml.split("\n");
94
+ const out = [];
95
+ for (let index = 0; index < lines.length; index += 1) {
96
+ const line = lines[index] ?? "";
97
+ out.push(line);
98
+ const match = /^(\s*)agent:\s*(?:#.*)?$/.exec(line);
99
+ if (!match)
100
+ continue;
101
+ const base = match[1]?.length ?? 0;
102
+ if (blockHasKey(lines, index + 1, base, "sandbox"))
103
+ continue;
104
+ const child = childIndent(lines, index + 1, base);
105
+ const pad = " ".repeat(child);
106
+ out.push(`${pad}sandbox: docker`);
107
+ if (sandbox.image)
108
+ out.push(`${pad}image: ${yamlDoubleQuote(sandbox.image)}`);
109
+ out.push(`${pad}allowedDomains:`);
110
+ for (const domain of sandbox.allowDomains)
111
+ out.push(`${pad} - ${domain}`);
112
+ }
113
+ return out.join("\n");
114
+ }
115
+ /**
116
+ * True when this job's rendered workflow sandboxes agents.
117
+ * A job that has not been rendered yet follows the current config.
118
+ */
119
+ export function jobIsSandboxed(config, jobId, workflow) {
120
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(workflow)) {
121
+ return resolveSandbox(config.sandbox, config.workflowSandbox[workflow]).mode === "docker";
122
+ }
123
+ const dir = join(config.kilnDir, "rendered", jobBasename(jobId));
124
+ const names = workflow === "fix" ? ["fix.yaml"] : [`${workflow}.yaml`, "fix.yaml"];
125
+ let sawRender = false;
126
+ for (const name of names) {
127
+ try {
128
+ const text = readFileSync(join(dir, name), "utf8");
129
+ sawRender = true;
130
+ if (/^\s*sandbox:\s*docker\s*$/m.test(text))
131
+ return true;
132
+ }
133
+ catch {
134
+ // That render is absent.
135
+ }
136
+ }
137
+ if (sawRender)
138
+ return false;
139
+ return resolveSandbox(config.sandbox, config.workflowSandbox[workflow]).mode === "docker";
140
+ }
141
+ export function yakDistHonorsAllowlist(distSource) {
142
+ return distSource.includes(YAK_ALLOWLIST_MARKER);
143
+ }
144
+ export function credentialPresent(env) {
145
+ return Boolean(env.ANTHROPIC_API_KEY?.trim() || env.CLAUDE_CODE_OAUTH_TOKEN?.trim());
146
+ }
147
+ /**
148
+ * Names every missing piece. Returns undefined when this sandbox can launch.
149
+ * The text never includes an environment value.
150
+ */
151
+ export function sandboxPreflightFor(sandbox, adapter, facts) {
152
+ if (sandbox.mode !== "docker")
153
+ return undefined;
154
+ return formatPreflight(missingPieces([sandbox.image ?? AGENT_SANDBOX_IMAGE], adapter, facts));
155
+ }
156
+ /** Union of every workflow whose resolved mode is docker. */
157
+ export function sandboxPreflight(config, facts) {
158
+ const images = requiredSandboxImages(config);
159
+ if (images.length === 0)
160
+ return undefined;
161
+ const agentImages = images.filter((image) => image !== PROXY_SANDBOX_IMAGE);
162
+ return formatPreflight(missingPieces(agentImages, config.adapter, facts));
163
+ }
164
+ export function sandboxPreflightMessage(config, facts) {
165
+ if (!sandboxIsEnabled(config))
166
+ return undefined;
167
+ return sandboxPreflight(config, facts ?? probeSandboxEnvironment(process.env, requiredSandboxImages(config)));
168
+ }
169
+ /**
170
+ * `docker` argv yak 0.7.1 builds for one sandboxed agent step
171
+ * (`dockerAgentRunArgs` in `@lchase/yak`). Kiln does not spawn docker
172
+ * itself. Credential values are copied through so a test can show the
173
+ * shape; do not log the result when `env` holds a real token.
174
+ */
175
+ export function agentSandboxDockerArgs(input) {
176
+ const env = input.env ?? {};
177
+ const credentialEnv = ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"]
178
+ .filter((key) => env[key] !== undefined)
179
+ .flatMap((key) => ["-e", `${key}=${env[key]}`]);
180
+ return [
181
+ "run",
182
+ "--rm",
183
+ "-i",
184
+ "--network",
185
+ input.networkName,
186
+ "-v",
187
+ `${input.cwd}:${CONTAINER_WORKDIR}`,
188
+ "-w",
189
+ CONTAINER_WORKDIR,
190
+ "-e",
191
+ `HTTPS_PROXY=${input.proxyUrl}`,
192
+ "-e",
193
+ `HTTP_PROXY=${input.proxyUrl}`,
194
+ ...credentialEnv,
195
+ input.image ?? AGENT_SANDBOX_IMAGE,
196
+ CONTAINER_CLI_PATH,
197
+ ...input.cliArgs,
198
+ ];
199
+ }
200
+ /** Proxy sidecar argv from yak's `startProxyArgs`. */
201
+ export function proxySandboxDockerArgs(networkName, allowedDomains) {
202
+ return [
203
+ "run",
204
+ "-d",
205
+ "--rm",
206
+ "--name",
207
+ `${networkName}-proxy`,
208
+ "--network",
209
+ networkName,
210
+ "-e",
211
+ `ALLOWED_DOMAINS=${allowedDomains.join(",")}`,
212
+ PROXY_SANDBOX_IMAGE,
213
+ ];
214
+ }
215
+ export function sandboxImageBuildArgs(yakVersion) {
216
+ const ref = `yak-v${yakVersion}`;
217
+ const remote = "https://github.com/lchase/yak.git";
218
+ return [
219
+ ["build", "-t", AGENT_SANDBOX_IMAGE, `${remote}#${ref}:docker/agent-sandbox`],
220
+ ["build", "-t", PROXY_SANDBOX_IMAGE, `${remote}#${ref}:docker/agent-proxy`],
221
+ ];
222
+ }
223
+ /** git is not in yak's node:22-slim agent image. Agents shell out to git. */
224
+ export function sandboxGitOverlayDockerfile() {
225
+ return [
226
+ "FROM yak-agent-sandbox:latest",
227
+ "USER root",
228
+ "RUN apt-get update \\",
229
+ " && apt-get install -y --no-install-recommends git ca-certificates \\",
230
+ " && rm -rf /var/lib/apt/lists/*",
231
+ "",
232
+ ].join("\n");
233
+ }
234
+ export function renderSandboxStatus(config, facts) {
235
+ const names = [...new Set(["feature", "investigation", "fix", ...Object.keys(config.workflowSandbox)])].sort();
236
+ const lines = names.map((name) => {
237
+ const resolved = resolveSandbox(config.sandbox, config.workflowSandbox[name]);
238
+ const image = resolved.image ? ` image ${resolved.image}` : "";
239
+ const domains = resolved.mode === "docker" ? ` allow ${resolved.allowDomains.join(" ")}` : "";
240
+ return `${name} ${resolved.mode}${image}${domains}`;
241
+ });
242
+ const problem = sandboxPreflightMessage(config, facts);
243
+ lines.push(problem ?? (sandboxIsEnabled(config) ? "sandbox ready" : "sandbox off"));
244
+ return `${lines.join("\n")}\n`;
245
+ }
246
+ export function runSandboxBuild() {
247
+ const version = installedYakVersion();
248
+ for (const args of sandboxImageBuildArgs(version))
249
+ runDocker(args);
250
+ const dir = mkdtempSync(join(tmpdir(), "kiln-sandbox-"));
251
+ const file = join(dir, "Dockerfile");
252
+ writeFileSync(file, sandboxGitOverlayDockerfile());
253
+ runDocker(["build", "-t", AGENT_SANDBOX_IMAGE, "-f", file, dir]);
254
+ }
255
+ export function probeSandboxEnvironment(env = process.env, images = [AGENT_SANDBOX_IMAGE, PROXY_SANDBOX_IMAGE]) {
256
+ const binary = dockerResult(["--version"]);
257
+ const dockerBinary = binary.status === 0;
258
+ const daemon = dockerBinary ? dockerResult(["info"]) : undefined;
259
+ const present = dockerBinary && daemon?.status === 0
260
+ ? images.filter((name) => dockerResult(["image", "inspect", name]).status === 0)
261
+ : [];
262
+ const yak = readInstalledYak();
263
+ return {
264
+ dockerBinary,
265
+ dockerDaemon: daemon?.status === 0,
266
+ images: present,
267
+ yakVersion: yak.version,
268
+ yakDist: yak.dist,
269
+ env,
270
+ };
271
+ }
272
+ function missingPieces(agentImages, adapter, facts) {
273
+ const missing = [];
274
+ if (!facts.dockerBinary) {
275
+ missing.push("docker binary is not on PATH");
276
+ }
277
+ else if (!facts.dockerDaemon) {
278
+ missing.push("docker daemon is not reachable");
279
+ }
280
+ else {
281
+ if (!facts.images.includes(PROXY_SANDBOX_IMAGE)) {
282
+ missing.push(`image ${PROXY_SANDBOX_IMAGE} is missing (kiln sandbox build)`);
283
+ }
284
+ for (const image of agentImages) {
285
+ if (!facts.images.includes(image))
286
+ missing.push(`image ${image} is missing (kiln sandbox build)`);
287
+ }
288
+ }
289
+ if (adapter === "claude-code" && !credentialPresent(facts.env)) {
290
+ missing.push("no Claude credential for the sandbox. The container only receives ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN. macOS keychain login is not forwarded. Run `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN for this kiln process.");
291
+ }
292
+ if (!yakDistHonorsAllowlist(facts.yakDist)) {
293
+ missing.push(`installed @lchase/yak ${facts.yakVersion} does not pass allowedDomains from a workflow step into the sandbox proxy. @lchase/yak 0.7.1 drops sandbox, image, and allowedDomains when it loads workflow YAML, and the proxy allowlist stays api.anthropic.com. Kiln will not launch an unsandboxed run while sandbox mode is docker.`);
294
+ }
295
+ return missing;
296
+ }
297
+ function formatPreflight(missing) {
298
+ if (missing.length === 0)
299
+ return undefined;
300
+ return `${SANDBOX_PREFLIGHT_PREFIX}\n${missing.map((line) => `- ${line}`).join("\n")}`;
301
+ }
302
+ function childIndent(lines, start, base) {
303
+ for (let index = start; index < lines.length; index += 1) {
304
+ const line = lines[index] ?? "";
305
+ if (line.trim().length === 0)
306
+ continue;
307
+ const indent = /^(\s*)/.exec(line)?.[1]?.length ?? 0;
308
+ if (indent <= base)
309
+ break;
310
+ return indent;
311
+ }
312
+ return base + 2;
313
+ }
314
+ function blockHasKey(lines, start, base, key) {
315
+ for (let index = start; index < lines.length; index += 1) {
316
+ const line = lines[index] ?? "";
317
+ if (line.trim().length === 0)
318
+ continue;
319
+ const indent = /^(\s*)/.exec(line)?.[1]?.length ?? 0;
320
+ if (indent <= base)
321
+ return false;
322
+ if (new RegExp(`^\\s*${key}:\\s*`).test(line))
323
+ return true;
324
+ }
325
+ return false;
326
+ }
327
+ function yamlDoubleQuote(value) {
328
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
329
+ }
330
+ function installedYakVersion() {
331
+ return readInstalledYak().version;
332
+ }
333
+ function readInstalledYak() {
334
+ const require = createRequire(import.meta.url);
335
+ const pkgPath = require.resolve("@lchase/yak/package.json");
336
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
337
+ const version = typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : "unknown";
338
+ let dist = "";
339
+ try {
340
+ dist = readFileSync(join(dirname(pkgPath), "dist", "index.js"), "utf8");
341
+ }
342
+ catch {
343
+ dist = "";
344
+ }
345
+ return { version, dist };
346
+ }
347
+ function dockerResult(args) {
348
+ const result = spawnSync("docker", args, { encoding: "utf8", stdio: "ignore" });
349
+ return { status: result.error ? null : result.status };
350
+ }
351
+ function runDocker(args) {
352
+ const result = spawnSync("docker", args, { stdio: "inherit" });
353
+ if (result.error) {
354
+ const code = result.error.code;
355
+ if (code === "ENOENT") {
356
+ throw new Error("docker binary is not on PATH. Install Docker and run kiln sandbox build again.");
357
+ }
358
+ throw new Error(`docker ${args[0] ?? "build"} failed: ${result.error.message}`);
359
+ }
360
+ if (result.status !== 0) {
361
+ throw new Error(`docker ${args.join(" ")} exited ${result.status ?? "with a signal"}.`);
362
+ }
363
+ }
package/dist/schemas.js CHANGED
@@ -26,6 +26,22 @@ export const JiraConfigSchema = z.object({
26
26
  email: z.string().optional(),
27
27
  apiToken: z.string().optional(),
28
28
  });
29
+ /**
30
+ * Opt-in Docker sandbox. Omitted mode means off at that layer.
31
+ * `image` overrides yak's `yak-agent-sandbox:latest`.
32
+ * `allowDomains` replaces the built-in host list when set.
33
+ */
34
+ const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
35
+ export const SandboxSettingsSchema = z.object({
36
+ mode: z.enum(["off", "docker"]).optional(),
37
+ image: z
38
+ .string()
39
+ .min(1)
40
+ .regex(/^\S+$/, "image must not contain whitespace")
41
+ .optional(),
42
+ allowDomains: z.array(z.string().regex(HOSTNAME, "must be a hostname")).min(1).optional(),
43
+ });
44
+ const WORKFLOW_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
29
45
  /** No yak event and no log output for this long stalls a running job. `<= 0` disables it. */
30
46
  export const DEFAULT_STALL_TIMEOUT_MS = 15 * 60 * 1000;
31
47
  /** Transient failures retry until this many attempts, then the job is dead. */
@@ -48,6 +64,9 @@ export const KilnConfigFileSchema = z.object({
48
64
  */
49
65
  maxFixRounds: z.number().int().min(1).optional(),
50
66
  adapter: z.enum(["mock", "claude-code"]).optional(),
67
+ sandbox: SandboxSettingsSchema.optional(),
68
+ /** Per-workflow sandbox override. A set field replaces the global one; an omitted field inherits. */
69
+ workflows: z.record(z.string().regex(WORKFLOW_KEY), z.object({ sandbox: SandboxSettingsSchema.optional() })).optional(),
51
70
  repos: z.record(z.string(), RepoEntrySchema),
52
71
  jira: JiraConfigSchema.optional(),
53
72
  });
@@ -127,6 +127,7 @@ function card(item, stage) {
127
127
  <div class="branch repo">${escapeHtml(job.repo)}</div>
128
128
  <p class="title">${escapeHtml(job.title || job.jobId)}</p>
129
129
  ${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
130
+ ${sandboxHtml(job)}
130
131
  ${prLink(job)}
131
132
  ${roundsHtml(job, stage)}
132
133
  ${attemptHtml(job, stage)}
@@ -146,6 +147,7 @@ function sheet(item) {
146
147
  <div class="num">${escapeHtml(num)}</div>
147
148
  <h3>${escapeHtml(job.title || job.jobId)}</h3>
148
149
  ${branch ? `<div class="branch">${escapeHtml(branch)}</div>` : ""}
150
+ ${sandboxHtml(job)}
149
151
  ${prLink(job)}
150
152
  ${actionsHtml(item)}
151
153
  <button type="button" class="close" data-close aria-label="Close">×</button>
@@ -198,6 +200,11 @@ function actionsHtml(item) {
198
200
  return "";
199
201
  return `<div class="d-actions">${forms.join("")}</div>`;
200
202
  }
203
+ function sandboxHtml(job) {
204
+ if (!job.sandboxed)
205
+ return "";
206
+ return `<div class="meta">sandbox · docker</div>`;
207
+ }
201
208
  function roundsHtml(job, stage) {
202
209
  const rounds = job.rounds ?? [];
203
210
  const last = rounds[rounds.length - 1]?.n;
package/dist/serve.js CHANGED
@@ -5,6 +5,7 @@ import { answerJob, readStatus, retryJob, runTick, startupRecovery } from "./dri
5
5
  import { defaultHost } from "./host.js";
6
6
  import { INVESTIGATION_WORKFLOW } from "./findings.js";
7
7
  import { packageRoot } from "./paths.js";
8
+ import { SANDBOX_PREFLIGHT_PREFIX } from "./sandbox.js";
8
9
  import { renderConsole, renderMessagePage, renderTextPage } from "./serve-page.js";
9
10
  const BODY_LIMIT = 65_536;
10
11
  const TEXT_LIMIT = 100_000;
@@ -71,7 +72,12 @@ export function loadServeCss() {
71
72
  }
72
73
  function safeTick(config, secrets, startup) {
73
74
  try {
74
- runTick(config, defaultHost(), { startup });
75
+ const report = runTick(config, defaultHost(), { startup });
76
+ if (report.startsWith(SANDBOX_PREFLIGHT_PREFIX)) {
77
+ const tickAt = report.indexOf("\ntick ");
78
+ const head = tickAt === -1 ? report : report.slice(0, tickAt);
79
+ process.stderr.write(`${redact(head.trimEnd(), secrets)}\n`);
80
+ }
75
81
  }
76
82
  catch (error) {
77
83
  const message = error instanceof Error ? error.message : String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lchase/kiln",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Run a workflow on any registered repo. A markdown spec is the input.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,10 @@
1
1
  # One Kiln job, run by yak (@lchase/yak).
2
2
  #
3
+ # Sandbox (kiln config `sandbox.mode: docker`, off by default): tick injects
4
+ # `sandbox: docker` under each agent step (spec, build, security, ux, ui,
5
+ # code, approver). verify, the approve gate, and land stay on the host.
6
+ # Pull request open and `gh pr view` run in the kiln process.
7
+ #
3
8
  # Faithful to Lawrence Chase's factory plugin v0.7.0 (MIT):
4
9
  # https://github.com/lchase/coding-agent-plugins/tree/4c84a2ce7fbde5ac298c0de9f0b90141eac4ed98/plugins/factory
5
10
  # skills/factory/SKILL.md (the loop: spec, build, 4 reviews, approver)
@@ -1,5 +1,9 @@
1
1
  # One pull-request fix, run by yak (@lchase/yak).
2
2
  #
3
+ # Sandbox (kiln config `sandbox.mode: docker`, off by default): tick injects
4
+ # `sandbox: docker` under the build agent. land stays on the host, and Kiln
5
+ # fast-forwards the pull request branch from the host.
6
+ #
3
7
  # The same builder as workflows/feature.yaml (factory builder, opus). Kiln
4
8
  # launches it with `yak run --isolation worktree --from <pull request head>`
5
9
  # on a job whose pull request is already open. Yak cuts a worktree branch
@@ -1,5 +1,8 @@
1
1
  # One Kiln investigation, run by yak (@lchase/yak).
2
2
  #
3
+ # Sandbox (kiln config `sandbox.mode: docker`, off by default): tick injects
4
+ # `sandbox: docker` under the explore agent. The approve gate stays on the host.
5
+ #
3
6
  # A spike, probe, or research ticket. The agent explores the worktree and
4
7
  # returns a findings document. A person approves or rejects it. There is no
5
8
  # build, review, verify, or land step, and Kiln does not open a pull request