Give a coder session a shell

7eab6f8e444f · AtlantisPleb · · parent f5434ba2f82c

Give a coder session a shell

Asked for its working directory, the session started a child coding agent on a
hosted model to run `pwd`, waited for it, and reported the answer second-hand.
That is minutes and real money for one line of output, and it was the only way
to look at anything: reading a file, listing a directory, running the tests, all
of it through a whole agent or through the reader.

The `shell` tool runs a command here instead. Both streams come back in the
order they arrived, because the error output is usually the part worth reading
and separating them loses which line came before which. A failing command
reports its exit code, since an empty failure reads as an empty success. There
is no terminal, so a command that would prompt gets end-of-file and stops rather
than waiting where nobody can see it. No login shell either: rc files are slow,
and a command whose behaviour depends on an interactive profile will not
reproduce.

`delegate` goes back to what it is for. A fan-out is worth an agent each; `pwd`
is worth a process.

The refusal list is short and aimed only at what cannot be undone: erasing a
root or a home, reformatting, writing over a raw device, halting the machine, a
fork bomb. It is not a security boundary and cannot be one -- a command can be
assembled from variables, decoded, or written to a file and run, and no list of
patterns sees that. It catches the accident, not the intent, and the comment at
the top of the module says so rather than implying more.

What decides is the target, not the verb. `rm -rf` on a build directory is
ordinary work and is allowed; on `/` or `~` it is refused, because nobody means
it. A list that stopped `rm -rf node_modules` would be switched off within a
day, so the tests fix both halves: thirteen commands that must be refused and
eight ordinary ones that must not be.

This also caught a sentence that had gone false. The system message told the
model it had "no file, shell, search, or web tools of your own" -- true when it
was written, and wrong the moment this landed. It now states the tool list as
closed rather than naming what is absent, because what is absent changes and a
message that must be edited when the tool list changes is one that will be wrong
in between.

376 tests pass, thirty new.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-ollama.ts
  • added packages/openagents-cli/src/coder-shell.ts
  • modified packages/openagents-cli/src/coder-tools.ts
  • modified packages/openagents-cli/test/coder-ollama.test.ts
  • added packages/openagents-cli/test/coder-shell.test.ts

Diff

8 files changed, +355 -12

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2434,
7
    "filesScanned": 2435,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:148589c7a0eaf2212132813961369d469f43d46e01a7ed722b0b458abb9ca63c",
4
  "sourceDigest": "sha256:df1ae247646ce2032b886c6ad9a2641425b70af96750dec46faa193b2ac1cba6",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (34 tracked test files)"
1879
          "ref": "packages/openagents-cli (35 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +2 -1

@@ -35,7 +35,7 @@ import {

35 35
  parseOllamaModelFlag,
36 36
} from "./coder-ollama.js";
37 37
import { openThread, ThreadUnavailable, type ThreadReplySource } from "./coder-thread.js";
38
import { delegateTool, openagentsTool, skillTool } from "./coder-tools.js";
38
import { delegateTool, openagentsTool, shellTool, skillTool } from "./coder-tools.js";
39 39
import { spawnSync } from "node:child_process";
40 40
41 41
import { rebuild, RELOAD_EXIT_CODE, sourceCheckout } from "./coder-reload.js";

@@ -1770,6 +1770,7 @@ const coderCommand = Command.make(

1770 1770
      const declareTools = () => {
1771 1771
        const active = skills.active();
1772 1772
        const tools = [
1773
          shellTool(process.cwd()),
1773 1774
          ...(active.length === 0 ? [] : [skillTool(active)]),
1774 1775
          openagentsTool(),
1775 1776
          ...(setup === undefined ? [] : [delegateTool(setup.delegation)]),
packages/openagents-cli/src/coder-ollama.ts modified +12 -7

@@ -113,19 +113,24 @@ const systemPrompt = (tools: ReadonlyArray<CoderTool>): string => {

113 113
114 114
  if (tools.length === 0) {
115 115
    lines.push(
116
      "You have no tools in this session. You cannot read or write files, run commands, search " +
117
        "the repository, or fetch a URL. Answer from what the reader tells you, and say plainly " +
118
        "when something would need a tool you do not have.",
116
      "You have no tools in this session: you cannot read or write files, run commands, or " +
117
        "reach anything outside this conversation. Answer from what the reader tells you, and " +
118
        "say plainly when something would need a tool you do not have.",
119 119
    );
120 120
  } else {
121 121
    lines.push(
122 122
      `You have ${String(tools.length)} tool${tools.length === 1 ? "" : "s"}, and no others:`,
123 123
      ...tools.map((tool) => `- \`${tool.name}\``),
124 124
      "",
125
      "That list is complete. You have no file, shell, search, or web tools of your own: any " +
126
        "capability not on that list is one you do not have. Where a tool's description says what " +
127
        "a child agent can do, that is the child's capability and not yours. Never say you ran " +
128
        "something you did not run.",
125
      // Stated as a closed list rather than by naming the capabilities that are
126
      // absent. The absent ones change as tools are added -- this once said
127
      // there was no shell, and then there was one -- and a system message that
128
      // has to be edited when the tool list changes is one that will be wrong
129
      // in between.
130
      "That list is complete: a capability not on it is one you do not have, whatever a model " +
131
        "like you usually has. Read a tool's description before assuming what it covers. Where " +
132
        "a description says what a child agent can do, that is the child's capability and not " +
133
        "yours. Never say you ran something you did not run.",
129 134
    );
130 135
  }
131 136
packages/openagents-cli/src/coder-shell.ts added +154

@@ -0,0 +1,154 @@

1
/**
2
 * Running a command on this machine.
3
 *
4
 * Without this, a session that wanted `pwd` had to start a child coding agent
5
 * on a hosted model to run it. That is minutes and real money for a line of
6
 * output, and the answer arrives second-hand.
7
 *
8
 * The session runs commands directly instead, and `delegate` goes back to what
9
 * it is for: work that is worth a whole agent.
10
 *
11
 * ## What the refusal list is, and is not
12
 *
13
 * It stops a small number of irreversible mistakes: erasing a home directory or
14
 * a disk, reformatting, halting the machine. It is not a security boundary and
15
 * cannot be one -- a command can be assembled from variables, decoded, or
16
 * written to a file and run, and no list of patterns sees that. It catches the
17
 * accident, not the intent.
18
 *
19
 * So it is kept short and aimed only at what cannot be undone. `rm -rf` on a
20
 * build directory is ordinary work and is allowed; `rm -rf` on `/` or `~` is
21
 * not, because no one means it.
22
 */
23
24
import { spawn } from "node:child_process";
25
26
/** How much of a command's output the model is shown. */
27
const OUTPUT_LIMIT = 30_000;
28
29
/** The default deadline, and the longest one a caller may ask for. */
30
const DEFAULT_TIMEOUT_MS = 120_000;
31
const MAXIMUM_TIMEOUT_MS = 600_000;
32
33
/**
34
 * Commands that cannot be undone, and are never what was meant.
35
 *
36
 * Each is paired with what to say, because a bare refusal reads as the tool
37
 * being broken rather than as the command being the problem.
38
 */
39
const REFUSED: ReadonlyArray<readonly [RegExp, string]> = [
40
  [
41
    // `rm -rf` aimed at a root, a home, or everything in one. Aimed at a build
42
    // directory it is ordinary work, so the target is what decides.
43
    /\brm\s+(-[a-zA-Z]*\s+)*-?[a-zA-Z]*[rR][a-zA-Z]*f?[a-zA-Z]*\s+(-[a-zA-Z]+\s+)*(\/|~|\$HOME|\/\*|~\/\*|\$HOME\/\*)(\s|$)/,
44
    "That would erase a root or a home directory.",
45
  ],
46
  [/\bmkfs(\.\w+)?\b/, "That would reformat a filesystem."],
47
  [/\bdd\b[^\n]*\bof=\/dev\/(disk|rdisk|sd|nvme|hd)/, "That would write over a raw device."],
48
  [/\bdiskutil\s+(erase|reformat|partition)/, "That would erase or repartition a disk."],
49
  [/\b(shutdown|reboot|halt|poweroff)\b/, "That would stop this machine."],
50
  [/:\(\)\s*\{\s*:\|:&\s*\}\s*;\s*:/, "That is a fork bomb."],
51
  [
52
    /\bchmod\s+(-[a-zA-Z]+\s+)*(-R|--recursive)\s+[0-7]{3,4}\s+(\/|~|\$HOME)(\s|$)/,
53
    "That would change the permissions of a whole root or home directory.",
54
  ],
55
  [/>\s*\/dev\/(disk|rdisk|sd|nvme|hd)/, "That would write over a raw device."],
56
];
57
58
/** Why this command will not be run, or undefined when it will. */
59
export const refusalFor = (command: string): string | undefined => {
60
  for (const [pattern, reason] of REFUSED) {
61
    if (pattern.test(command)) {
62
      return `${reason} This session refuses it. If you meant something narrower, name the directory.`;
63
    }
64
  }
65
  return undefined;
66
};
67
68
export interface ShellResult {
69
  readonly output: string;
70
  readonly code: number | undefined;
71
  readonly timedOut: boolean;
72
}
73
74
/**
75
 * Run one command and report what it said.
76
 *
77
 * Both streams are collected in the order they arrive, because a command's
78
 * error output is usually the part worth reading and separating them loses
79
 * which line came before which.
80
 *
81
 * No login shell: rc files are slow, and a command whose behaviour depends on
82
 * an interactive profile is one that will not reproduce.
83
 */
84
export async function runShell(
85
  command: string,
86
  options: { readonly cwd: string; readonly timeoutMs?: number; readonly signal: AbortSignal },
87
): Promise<ShellResult> {
88
  const timeoutMs = Math.min(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, MAXIMUM_TIMEOUT_MS);
89
90
  return await new Promise<ShellResult>((resolve) => {
91
    const child = spawn("/bin/sh", ["-c", command], {
92
      cwd: options.cwd,
93
      // No terminal, so a command that would prompt reads end-of-file and
94
      // stops rather than waiting where nobody can see it.
95
      stdio: ["ignore", "pipe", "pipe"],
96
    });
97
98
    let output = "";
99
    let settled = false;
100
    const finish = (result: ShellResult) => {
101
      if (settled) return;
102
      settled = true;
103
      clearTimeout(timer);
104
      options.signal.removeEventListener("abort", onAbort);
105
      resolve(result);
106
    };
107
108
    const timer = setTimeout(() => {
109
      child.kill("SIGKILL");
110
      finish({ output, code: undefined, timedOut: true });
111
    }, timeoutMs);
112
113
    const onAbort = () => {
114
      child.kill("SIGKILL");
115
      finish({ output, code: undefined, timedOut: false });
116
    };
117
    options.signal.addEventListener("abort", onAbort, { once: true });
118
119
    const collect = (chunk: Buffer) => {
120
      if (output.length < OUTPUT_LIMIT) output += chunk.toString("utf8");
121
    };
122
    child.stdout.on("data", collect);
123
    child.stderr.on("data", collect);
124
125
    child.on("error", (cause) => {
126
      finish({ output: `The command could not be started: ${cause.message}`, code: undefined, timedOut: false });
127
    });
128
    child.on("close", (code) => {
129
      finish({ output, code: code ?? undefined, timedOut: false });
130
    });
131
  });
132
}
133
134
/** What the model is shown for one run. */
135
export const renderShell = (result: ShellResult, timeoutMs: number): string => {
136
  const bounded =
137
    result.output.length > OUTPUT_LIMIT
138
      ? `${result.output.slice(0, OUTPUT_LIMIT)}\n\n[truncated; narrow the command or write to a file and read part of it]`
139
      : result.output;
140
  const body = bounded.trim();
141
142
  if (result.timedOut) {
143
    return `The command did not finish within ${String(Math.round(timeoutMs / 1000))}s and was stopped.\n\n${body}`;
144
  }
145
  if (result.code === undefined) return body.length === 0 ? "The command was interrupted." : body;
146
  // The exit code is reported on failure because an empty failure reads as an
147
  // empty success, and a command that fails silently is the one that misleads.
148
  if (result.code !== 0) {
149
    return `The command exited with code ${String(result.code)}.\n\n${body}`;
150
  }
151
  return body.length === 0 ? "The command succeeded and printed nothing." : body;
152
};
153
154
export { DEFAULT_TIMEOUT_MS, MAXIMUM_TIMEOUT_MS, OUTPUT_LIMIT };
packages/openagents-cli/src/coder-tools.ts modified +69

@@ -18,6 +18,13 @@ import { existsSync } from "node:fs";

18 18
import { fileURLToPath } from "node:url";
19 19
20 20
import type { CoderDelegation } from "./coder-session.js";
21
import {
22
  DEFAULT_TIMEOUT_MS,
23
  MAXIMUM_TIMEOUT_MS,
24
  refusalFor as shellRefusalFor,
25
  renderShell,
26
  runShell,
27
} from "./coder-shell.js";
21 28
import { catalogEntry, renderSkill, type CoderSkill } from "./coder-skills.js";
22 29
import { describePrompt, MAX_DELEGATE_COUNT } from "./coder-delegate.js";
23 30
import type { DelegationOutcome } from "./coder-delegate.js";

@@ -385,3 +392,65 @@ export function openagentsTool(): CoderTool {

385 392
    },
386 393
  };
387 394
}
395
396
/**
397
 * The shell tool: run a command on this machine.
398
 *
399
 * The session had no way to look at anything. Asked for its working directory
400
 * it started a child coding agent on a hosted model to run `pwd` -- minutes and
401
 * real money for one line, reported second-hand. Reading a file, listing a
402
 * directory, running the tests: all of it went through a whole agent or through
403
 * the reader.
404
 *
405
 * So `delegate` goes back to what it is for. A fan-out is worth an agent each;
406
 * `pwd` is worth a process.
407
 */
408
export function shellTool(cwd: string): CoderTool {
409
  return {
410
    name: "shell",
411
    description:
412
      "Run a shell command on this machine, in the session's working directory, and return what " +
413
      "it printed. Use it for anything you would type at a terminal: reading files, listing " +
414
      "directories, searching, git, running builds and tests. Prefer it over `delegate` for " +
415
      "single commands -- a child agent is for work worth a whole agent, not for one line of " +
416
      "output. Both output streams come back together with the exit code. There is no terminal, " +
417
      "so a command that would prompt gets end-of-file instead of waiting; pass a flag that " +
418
      "answers the prompt. A few commands that cannot be undone are refused, such as erasing a " +
419
      "root or a home directory, reformatting a disk, or halting the machine.",
420
    parameters: {
421
      type: "object",
422
      properties: {
423
        command: {
424
          type: "string",
425
          description: "The command line, as you would type it. Runs through `/bin/sh -c`.",
426
        },
427
        timeout_seconds: {
428
          type: "integer",
429
          minimum: 1,
430
          maximum: MAXIMUM_TIMEOUT_MS / 1000,
431
          description: `How long to wait. Defaults to ${String(DEFAULT_TIMEOUT_MS / 1000)}; raise it for a build or a test run.`,
432
        },
433
      },
434
      required: ["command"],
435
      additionalProperties: false,
436
    },
437
    run: async (args, signal) => {
438
      const command = typeof args["command"] === "string" ? args["command"].trim() : "";
439
      if (command.length === 0) {
440
        return "No command was run: `command` is required and must say what to run.";
441
      }
442
443
      const refusal = shellRefusalFor(command);
444
      if (refusal !== undefined) return refusal;
445
446
      const asked = typeof args["timeout_seconds"] === "number" ? args["timeout_seconds"] : undefined;
447
      const timeoutMs = Math.min(
448
        asked === undefined ? DEFAULT_TIMEOUT_MS : Math.max(1, Math.trunc(asked)) * 1000,
449
        MAXIMUM_TIMEOUT_MS,
450
      );
451
452
      const result = await runShell(command, { cwd, timeoutMs, signal });
453
      return renderShell(result, timeoutMs);
454
    },
455
  };
456
}
packages/openagents-cli/test/coder-ollama.test.ts modified +4 -1

@@ -233,7 +233,10 @@ describe("what a local session tells the model about itself", () => {

233 233
    expect(system.content).toContain("`delegate`");
234 234
    // The failure this exists to stop: the model answering with the tools a
235 235
    // coding agent usually has rather than the ones it was given.
236
    expect(system.content).toContain("no file, shell, search, or web tools of your own");
236
    // Stated as a closed list, not by naming what is absent: this once said
237
    // there was no shell, and then a shell tool was added.
238
    expect(system.content).toContain("a capability not on it is one you do not have");
239
    expect(system.content).not.toMatch(/no file, shell/);
237 240
    // A tool description says what a child can do. That is not the model's.
238 241
    expect(system.content).toContain("that is the child's capability and not yours");
239 242
  });
packages/openagents-cli/test/coder-shell.test.ts added +111

@@ -0,0 +1,111 @@

1
import { mkdtempSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
import { describe, expect, it } from "vitest";
5
6
import { refusalFor } from "../src/coder-shell.js";
7
import { shellTool } from "../src/coder-tools.js";
8
9
const run = (command: string, timeout_seconds?: number) =>
10
  shellTool(process.cwd()).run(
11
    { command, ...(timeout_seconds === undefined ? {} : { timeout_seconds }) },
12
    new AbortController().signal,
13
  );
14
15
describe("what the shell refuses", () => {
16
  const refused = [
17
    "rm -rf /",
18
    "rm -rf ~",
19
    "sudo rm -rf /",
20
    "rm -rf $HOME",
21
    "rm -fr /",
22
    "rm -rf /*",
23
    "mkfs.ext4 /dev/disk2",
24
    "dd if=/dev/zero of=/dev/disk0",
25
    "diskutil eraseDisk JHFS+ X disk2",
26
    "sudo shutdown -h now",
27
    ":(){ :|:& };:",
28
    "chmod -R 777 /",
29
    "echo hi > /dev/disk0",
30
  ];
31
32
  it.each(refused)("refuses %s", (command) => {
33
    expect(refusalFor(command)).toBeDefined();
34
  });
35
36
  const allowed = [
37
    // The one that matters: cleaning a build directory is ordinary work, and a
38
    // list that stopped it would be turned off within a day.
39
    "rm -rf node_modules",
40
    "rm -rf _build deps",
41
    "rm -rf ./dist",
42
    "rm file.txt",
43
    "git status",
44
    "mix test",
45
    "grep -rn foo src/",
46
    "find . -name '*.ex' -delete",
47
  ];
48
49
  it.each(allowed)("allows %s", (command) => {
50
    expect(refusalFor(command)).toBeUndefined();
51
  });
52
53
  it("says what was wrong with it, not just that it was refused", async () => {
54
    const output = await run("rm -rf /");
55
56
    // A bare refusal reads as the tool being broken rather than as the command
57
    // being the problem.
58
    expect(output).toContain("erase a root or a home directory");
59
    expect(output).toContain("name the directory");
60
  });
61
});
62
63
describe("running a command", () => {
64
  it("returns what it printed", async () => {
65
    await expect(run("echo hello")).resolves.toBe("hello");
66
  });
67
68
  it("keeps both streams, in the order they arrived", async () => {
69
    const output = await run("echo first; echo second >&2");
70
71
    // The error output is usually the part worth reading, and separating the
72
    // streams loses which line came before which.
73
    expect(output).toContain("first");
74
    expect(output).toContain("second");
75
  });
76
77
  it("reports a failing command by its exit code", async () => {
78
    // An empty failure reads as an empty success.
79
    await expect(run("exit 3")).resolves.toContain("exited with code 3");
80
  });
81
82
  it("says a silent success was a success", async () => {
83
    await expect(run("true")).resolves.toContain("succeeded and printed nothing");
84
  });
85
86
  it("runs in the working directory it was given", async () => {
87
    const directory = mkdtempSync(join(tmpdir(), "coder-shell-"));
88
    writeFileSync(join(directory, "marker.txt"), "here");
89
90
    const output = await shellTool(directory).run(
91
      { command: "ls" },
92
      new AbortController().signal,
93
    );
94
95
    expect(output).toContain("marker.txt");
96
  });
97
98
  it("stops a command that runs too long, and says so", async () => {
99
    await expect(run("sleep 5", 1)).resolves.toContain("did not finish within 1s");
100
  });
101
102
  it("does not wait on a command that would prompt", async () => {
103
    // No terminal, so the read gets end-of-file rather than waiting where
104
    // nobody can see it.
105
    await expect(run("read x", 5)).resolves.toContain("exited with code");
106
  });
107
108
  it("asks for a command rather than running nothing", async () => {
109
    await expect(run("   ")).resolves.toContain("`command` is required");
110
  });
111
});

This page updates live while a promote is in flight · changelog