Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/core/src/tool/builtins.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export * as BuiltInTools from "./builtins"

import { Layer } from "effect"
import { BashTool } from "./bash"
import { TerminalTool } from "./terminal"
import { ApplyPatchTool } from "./apply-patch"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
Expand Down Expand Up @@ -30,7 +30,7 @@ import { WriteTool } from "./write"
*/
export const locationLayer = Layer.mergeAll(
ApplyPatchTool.layer,
BashTool.layer,
TerminalTool.layer,
EditTool.layer,
GlobTool.layer,
GrepTool.layer,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export * as BashTool from "./bash"
export * as TerminalTool from "./terminal"

import path from "path"
import { ToolFailure } from "@opencode-ai/llm"
Expand All @@ -13,7 +13,7 @@ import { PositiveInt } from "../schema"
import { Tool } from "./tool"
import { Tools } from "./tools"

export const name = "bash"
export const name = "terminal"
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/v1/config/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ const InputObject = Schema.StructWithRest(
glob: Schema.optional(Rule),
grep: Schema.optional(Rule),
list: Schema.optional(Rule),
bash: Schema.optional(Rule),
bash: Schema.optional(Rule), // kept for backward compatibility but mapped to "terminal"
terminal: Schema.optional(Rule),
task: Schema.optional(Rule),
external_directory: Schema.optional(Rule),
todowrite: Schema.optional(Action),
Expand All @@ -37,8 +38,14 @@ const InputObject = Schema.StructWithRest(

const InputSchema = Schema.Union([Action, InputObject])

const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> =>
typeof input === "string" ? { "*": input } : input
const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> => {
if (typeof input === "string") return { "*": input }
const result = { ...input }
if (result.terminal !== undefined && result.bash !== undefined) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a user's config defines only the legacy bash permission key (the common backward-compat case, with no terminal key), the decoded config.permission object is returned unchanged with the bash key still present. The two updated config tests (''config parser preserves permission order...'' and ''agent markdown permission config preserves user key order'') both use only bash and assert the parsed keys are ["terminal", "*", "edit"], but since normalizeInput only deletes bash when terminal is also set, the object keeps bash and the tests fail. The runtime ruleset path in Permission.fromConfig does remap bashterminal, so enforcement is correct, but the exposed config.permission stays inconsistent with the rename. Rename a lone bash key to terminal (and drop it only when terminal is also defined).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/v1/config/permission.ts, line 44:

<comment>When a user's config defines only the legacy `bash` permission key (the common backward-compat case, with no `terminal` key), the decoded `config.permission` object is returned unchanged with the `bash` key still present. The two updated config tests (''config parser preserves permission order...'' and ''agent markdown permission config preserves user key order'') both use only `bash` and assert the parsed keys are `["terminal", "*", "edit"]`, but since `normalizeInput` only deletes `bash` when `terminal` is *also* set, the object keeps `bash` and the tests fail. The runtime ruleset path in `Permission.fromConfig` does remap `bash`→`terminal`, so enforcement is correct, but the exposed `config.permission` stays inconsistent with the rename. Rename a lone `bash` key to `terminal` (and drop it only when `terminal` is also defined).</comment>

<file context>
@@ -37,8 +38,14 @@ const InputObject = Schema.StructWithRest(
+const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> => {
+  if (typeof input === "string") return { "*": input }
+  const result = { ...input }
+  if (result.terminal !== undefined && result.bash !== undefined) {
+    delete (result as any).bash
+  }
</file context>

delete (result as any).bash
}
return result
}
Comment on lines +41 to +48

export const Info = InputSchema.pipe(
Schema.decodeTo(InputObject, {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/config/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
{ action: "read", resource: "*", effect: "allow" },
{ action: "bash", resource: "git *", effect: "allow" },
])
expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
expect(PermissionV2.evaluate("terminal", "git status", buildAgent.permissions).effect).toBe("allow")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The renamed assertion calls evaluate("terminal", ...) but the permission rules under test still carry action "bash", which PermissionV2.evaluate matches by exact wildcard with no bash→terminal remap. "git status" therefore never matches the "bash" allow rule and the first assertion expecting "allow" will fail with "ask". Update the permission rules (and the expected buildAgent.permissions/toMatchObject lists) to use action "terminal", or keep evaluating with "bash" — the test is currently internally inconsistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/test/config/agent.test.ts, line 84:

<comment>The renamed assertion calls evaluate("terminal", ...) but the permission rules under test still carry action "bash", which PermissionV2.evaluate matches by exact wildcard with no bash→terminal remap. "git status" therefore never matches the "bash" allow rule and the first assertion expecting "allow" will fail with "ask". Update the permission rules (and the expected buildAgent.permissions/toMatchObject lists) to use action "terminal", or keep evaluating with "bash" — the test is currently internally inconsistent.</comment>

<file context>
@@ -81,8 +81,8 @@ describe("ConfigAgentPlugin.Plugin", () => {
       ])
-      expect(PermissionV2.evaluate("bash", "git status", buildAgent.permissions).effect).toBe("allow")
-      expect(PermissionV2.evaluate("bash", "bun test", buildAgent.permissions).effect).toBe("ask")
+      expect(PermissionV2.evaluate("terminal", "git status", buildAgent.permissions).effect).toBe("allow")
+      expect(PermissionV2.evaluate("terminal", "bun test", buildAgent.permissions).effect).toBe("ask")
 
</file context>

expect(PermissionV2.evaluate("terminal", "bun test", buildAgent.permissions).effect).toBe("ask")

const reviewer = yield* agents.get(AgentV2.ID.make("reviewer"))
if (!reviewer) throw new Error("expected configured reviewer agent")
Expand Down
5 changes: 2 additions & 3 deletions packages/core/test/session-runner-tool-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@ describe("ToolRegistry", () => {
const names = (rules: Parameters<ToolRegistry.Interface["materialize"]>[0]) =>
toolDefinitions(service, rules).pipe(Effect.map((definitions) => definitions.map((tool) => tool.name)))

expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
"bash",
expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["terminal",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The test registers tools via service.register({ question: make(), bash: make(), edit: ... }), and registry names are the registration keys (materialize calls definition(name, tool) with name from the key), so the fake shell tool materializes as "bash", not "terminal". The renamed expectation ["terminal", "edit", "write", "apply_patch"] (and the later ["question", "terminal"]) will now fail because the registration key bash: was never renamed. Rename the register key to terminal: to match, or the test breaks.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/test/session-runner-tool-registry.test.ts, line 69:

<comment>The test registers tools via `service.register({ question: make(), bash: make(), edit: ... })`, and registry names are the registration keys (materialize calls `definition(name, tool)` with `name` from the key), so the fake shell tool materializes as "bash", not "terminal". The renamed expectation `["terminal", "edit", "write", "apply_patch"]` (and the later `["question", "terminal"]`) will now fail because the registration key `bash:` was never renamed. Rename the register key to `terminal:` to match, or the test breaks.</comment>

<file context>
@@ -66,8 +66,7 @@ describe("ToolRegistry", () => {
 
-      expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual([
-        "bash",
+      expect(yield* names([{ action: "question", resource: "*", effect: "deny" }])).toEqual(["terminal",
         "edit",
         "write",
</file context>

"edit",
"write",
"apply_patch",
Expand All @@ -84,7 +83,7 @@ describe("ToolRegistry", () => {
{ action: "*", resource: "*", effect: "deny" },
]),
).toEqual([])
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "bash"])
expect(yield* names([{ action: "edit", resource: "*", effect: "deny" }])).toEqual(["question", "terminal"])
}),
)

Expand Down
4 changes: 2 additions & 2 deletions packages/core/test/session-tool-progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,14 @@ describe("Tool.Progress", () => {
timestamp,
assistantMessageID,
callID,
name: "bash",
name: "terminal",
})
yield* service.publish(SessionEvent.Tool.Called, {
sessionID,
timestamp,
assistantMessageID,
callID,
tool: "bash",
tool: "terminal",
input: { command: "pwd" },
provider: { executed: false },
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { AppProcess } from "@opencode-ai/core/process"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { BashTool } from "@opencode-ai/core/tool/bash"
import { TerminalTool } from "@opencode-ai/core/tool/terminal"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
Expand Down Expand Up @@ -102,7 +102,7 @@ const withTool = <A, E, R>(
)
const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const bash = BashTool.layer.pipe(
const bash = TerminalTool.layer.pipe(
Layer.provide(registry),
Layer.provide(permission),
Layer.provide(mutation),
Expand All @@ -115,15 +115,15 @@ const withTool = <A, E, R>(
}).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
}

const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({
const call = (input: typeof TerminalTool.Input.Type, id = "call-bash") => ({
sessionID,
...toolIdentity,
call: { type: "tool-call" as const, id, name: "bash", input },
call: { type: "tool-call" as const, id, name: "terminal", input },
})

const it = testEffect(Layer.empty)

describe("BashTool", () => {
describe("TerminalTool", () => {
it.live("registers and returns structured successful output from the active Location", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
Expand All @@ -132,9 +132,9 @@ describe("BashTool", () => {
return withTool(tmp.path, (registry) =>
Effect.gen(function* () {
const definitions = yield* toolDefinitions(registry)
expect(definitions.map((tool) => tool.name)).toEqual(["bash"])
expect(definitions.map((tool) => tool.name)).toEqual(["terminal"])
expect(definitions[0]?.inputSchema).not.toHaveProperty("properties.background")
expect(yield* toolDefinitions(registry, [{ action: "bash", resource: "*", effect: "deny" }])).toEqual([])
expect(yield* toolDefinitions(registry, [{ action: "terminal", resource: "*", effect: "deny" }])).toEqual([])
expect(
yield* settleTool(registry, call({ command: "pwd", description: "Print working directory" })),
).toEqual({
Expand All @@ -152,10 +152,10 @@ describe("BashTool", () => {
})
expect(runs).toMatchObject([{ command: "pwd", cwd: realpathSync(tmp.path) }])
expect(runs[0]?.options).toMatchObject({
maxOutputBytes: BashTool.MAX_CAPTURE_BYTES,
maxErrorBytes: BashTool.MAX_CAPTURE_BYTES,
maxOutputBytes: TerminalTool.MAX_CAPTURE_BYTES,
maxErrorBytes: TerminalTool.MAX_CAPTURE_BYTES,
})
expect(assertions).toMatchObject([{ sessionID, action: "bash", resources: ["pwd"], save: ["pwd"] }])
expect(assertions).toMatchObject([{ sessionID, action: "terminal", resources: ["pwd"], save: ["pwd"] }])
}),
)
},
Expand Down Expand Up @@ -188,7 +188,7 @@ describe("BashTool", () => {
reset()
const workdir = path.join(tmp.path, "src")
afterPermission = (input) =>
input.action === "bash"
input.action === "terminal"
? Effect.promise(async () => {
await fs.rm(workdir, { recursive: true })
await fs.writeFile(workdir, "not a directory")
Expand All @@ -201,7 +201,7 @@ describe("BashTool", () => {
Effect.andThen(
Effect.sync(() => {
expect(runs).toEqual([])
expect(assertions.map((input) => input.action)).toEqual(["bash"])
expect(assertions.map((input) => input.action)).toEqual(["terminal"])
}),
),
)
Expand Down Expand Up @@ -249,7 +249,7 @@ describe("BashTool", () => {
).pipe(
Effect.andThen(
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "bash"])
expect(assertions.map((item) => item.action)).toEqual(["external_directory", "terminal"])
expect(assertions[0]).toMatchObject({
resources: [path.join(realpathSync(outside.path), "*").replaceAll("\\", "/")],
})
Expand Down Expand Up @@ -281,7 +281,7 @@ describe("BashTool", () => {
reset()
denyAction = "bash"
yield* withTool(active.path, (registry) => executeTool(registry, call({ command: "pwd" })))
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(assertions.map((item) => item.action)).toEqual(["terminal"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The rename left denyAction = "bash" stale, so the deny branch never matches the tool's new "terminal" action and the tool executes, failing expect(runs).toEqual([]) in the 'does not execute after external-directory or bash denial' test. Change it to denyAction = "terminal" so the renamed test still verifies the denial path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/test/tool-terminal.test.ts, line 284:

<comment>The rename left `denyAction = "bash"` stale, so the deny branch never matches the tool's new `"terminal"` action and the tool executes, failing `expect(runs).toEqual([])` in the 'does not execute after external-directory or bash denial' test. Change it to `denyAction = "terminal"` so the renamed test still verifies the denial path.</comment>

<file context>
@@ -281,7 +281,7 @@ describe("BashTool", () => {
           denyAction = "bash"
           yield* withTool(active.path, (registry) => executeTool(registry, call({ command: "pwd" })))
-          expect(assertions.map((item) => item.action)).toEqual(["bash"])
+          expect(assertions.map((item) => item.action)).toEqual(["terminal"])
           expect(runs).toEqual([])
         }),
</file context>

expect(runs).toEqual([])
}),
([active, outside]) =>
Expand All @@ -301,7 +301,7 @@ describe("BashTool", () => {
return withTool(active.path, (registry) => settleTool(registry, call({ command: `cat ${target}` }))).pipe(
Effect.andThen((settled) =>
Effect.sync(() => {
expect(assertions.map((item) => item.action)).toEqual(["bash"])
expect(assertions.map((item) => item.action)).toEqual(["terminal"])
expect(runs).toHaveLength(1)
expect(settled.output?.structured).toMatchObject({
warnings: [
Expand Down Expand Up @@ -399,7 +399,7 @@ describe("BashTool", () => {
})

test("keeps locked deferred parity TODOs visible", async () => {
const source = await fs.readFile(new URL("../src/tool/bash.ts", import.meta.url), "utf8")
const source = await fs.readFile(new URL("../src/tool/terminal.ts", import.meta.url), "utf8")
for (const todo of [
"Port tree-sitter bash / PowerShell parser-based approval reduction.",
"Port BashArity reusable command-prefix approvals.",
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/specs/v2/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const opencode = OpenCode.make({})
opencode.tool.add(ReadTool)

opencode.tool.add({
name: "bash",
name: "terminal",
schema: {
type: "object",
properties: {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/acp/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ export class Subscription {
private async runningTool(sessionId: string, part: ToolPart, cwd: string) {
if (part.state.status !== "running") return

const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined
const output = part.tool === "terminal" ? shellOutputSnapshot(part.state) : undefined
if (output !== undefined) {
if (this.shellSnapshots.get(part.callID) === output) {
await this.input.connection.sessionUpdate({
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/acp/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ function shellCommand(input: ToolInput) {

function isShell(toolName: string) {
const tool = toolName.toLocaleLowerCase()
return tool === "bash" || tool === "shell"
return tool === "terminal" || tool === "shell"
Comment on lines 297 to +299

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add terminal to toLocations().

isShell() now recognizes "terminal", but toLocations() still handles "bash" and "shell". A terminal call therefore reaches the default branch and returns no ToolCallLocation. ACP clients lose the terminal working-directory location.

Add case "terminal": to the toLocations() switch. Keep case "bash": if legacy ACP messages must remain supported.

Proposed fix
 switch (tool) {
+  case "terminal":
   case "bash":
   case "shell": {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/acp/tool.ts` around lines 297 - 299, Update the
toLocations() switch to handle the "terminal" tool name and return the same
working-directory ToolCallLocation as the existing shell/bash handling. Preserve
the "bash" case for legacy ACP messages.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This rename only updated isShell to detect "terminal", but the sibling functions in the same file — toToolKind and toLocations — still switch on "bash"/"shell". Since the tool ID is now "terminal" (Tool.define("terminal")), ACP tool calls for the terminal tool will be classified as kind "other" instead of "execute" and will lose their shell working-directory location. Add case "terminal" to both switches for consistency with the rename.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/acp/tool.ts, line 299:

<comment>This rename only updated isShell to detect "terminal", but the sibling functions in the same file — toToolKind and toLocations — still switch on "bash"/"shell". Since the tool ID is now "terminal" (Tool.define("terminal")), ACP tool calls for the terminal tool will be classified as kind "other" instead of "execute" and will lose their shell working-directory location. Add case "terminal" to both switches for consistency with the rename.</comment>

<file context>
@@ -296,7 +296,7 @@ function shellCommand(input: ToolInput) {
 function isShell(toolName: string) {
   const tool = toolName.toLocaleLowerCase()
-  return tool === "bash" || tool === "shell"
+  return tool === "terminal" || tool === "shell"
 }
 
</file context>

}
Comment on lines 297 to 300

export const mapToolKind = toToolKind
Expand Down
32 changes: 16 additions & 16 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ export const layer = Layer.effect(
"*.env.*": "ask",
"*.env.example": "allow",
},
// altimate_change start - bash safety defaults for destructive file/git/DDL commands
// Safety defaults for bash commands.
// altimate_change start - terminal safety defaults for destructive file/git/DDL commands
// Safety defaults for terminal commands.
// IMPORTANT: "*": "ask" must come FIRST because evaluation uses last-match-wins.
//
// "ask" = user sees prompt and can approve. Used for destructive file/git
Expand All @@ -162,7 +162,7 @@ export const layer = Layer.effect(
// almost never intentional in an agent context.
//
// Users can override any of these in altimate-code.json.
bash: {
terminal: {
"*": "ask",
"rm -rf *": "ask",
"rm -fr *": "ask",
Expand All @@ -186,11 +186,11 @@ export const layer = Layer.effect(
// Safety deny rules that CANNOT be overridden by wildcard allows.
// Appended after user config so they always take precedence via last-match-wins.
// Users who need to override must use specific patterns like
// `"DROP DATABASE test_db": "allow"` — wildcard `bash: "allow"` won't work.
// `"DROP DATABASE test_db": "allow"` — wildcard `terminal: "allow"` won't work.
// Both UPPER and lowercase variants are included because Wildcard.match
// is case-sensitive on Linux/macOS.
const safetyDenials = Permission.fromConfig({
bash: {
terminal: {
"DROP DATABASE *": "deny",
"DROP SCHEMA *": "deny",
"TRUNCATE *": "deny",
Expand Down Expand Up @@ -291,8 +291,8 @@ export const layer = Layer.effect(
websearch: "allow",
question: "allow",
tool_lookup: "allow",
// Bash: last-match-wins — "*": "deny" MUST come first, then specific allows override
bash: {
// Terminal: last-match-wins — "*": "deny" MUST come first, then specific allows override
terminal: {
"*": "deny",
"ls *": "allow",
"grep *": "allow",
Expand All @@ -319,7 +319,7 @@ export const layer = Layer.effect(
reviewer: {
name: "reviewer",
description:
"dbt PR reviewer. Runs the dbt_pr_review verdict engine (lineage, equivalence, PII, grade) plus read-only analysis tools and posts findings. Edit/write tools are denied; bash prompts for approval.",
"dbt PR reviewer. Runs the dbt_pr_review verdict engine (lineage, equivalence, PII, grade) plus read-only analysis tools and posts findings. Edit/write tools are denied; terminal prompts for approval.",
prompt: PROMPT_REVIEWER,
options: {},
permission: Permission.merge(
Expand All @@ -340,7 +340,7 @@ export const layer = Layer.effect(
schema_detect_pii: "allow",
// Writes denied — review never mutates the project.
sql_execute_write: "deny",
// Read-only file + repo access (structured tools, not bash).
// Read-only file + repo access (structured tools, not terminal).
read: "allow",
grep: "allow",
glob: "allow",
Expand All @@ -354,20 +354,20 @@ export const layer = Layer.effect(
// Read-only web access so the reviewer can pull PR/issue URLs.
webfetch: "allow",
websearch: "allow",
// Bash PROMPTS instead of hard-denying (#978: `gh pr view` is the
// terminal PROMPTS instead of hard-denying (#978: `gh pr view` is the
// primary way to review a PR URL). A string-prefix allowlist can't
// safely bound argv (redirects ride inside the matched command), so
// every bash command requires explicit user approval here — the
// every terminal command requires explicit user approval here — the
// reviewer still never runs shell commands silently.
bash: "ask",
terminal: "ask",
}),
// altimate_change start — reviewer safety must not be overridable by a permissive user
// config (e.g. global `permission: {"*":"allow"}` or `bash:"allow"`). Merge user config,
// config (e.g. global `permission: {"*":"allow"}` or `terminal:"allow"`). Merge user config,
// THEN re-apply the reviewer read-only invariants, THEN safetyDenials LAST so DDL denies
// still win over the reviewer's bash:"ask". (edit covers write/edit/apply_patch.)
// still win over the reviewer's terminal:"ask". (edit covers write/edit/apply_patch.)
user,
Permission.fromConfig({
bash: "ask",
terminal: "ask",
edit: "deny",
sql_execute_write: "deny",
}),
Expand Down Expand Up @@ -435,7 +435,7 @@ export const layer = Layer.effect(
codesearch: "allow",
// altimate_change end
list: "allow",
bash: "allow",
terminal: "allow",
webfetch: "allow",
websearch: "allow",
read: "allow",
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/altimate/observability/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ function showDetail(span) {
if (fp) changedFiles[fp] = lname.indexOf('write') >= 0 ? 'write' : 'edit';
} else if (lname.indexOf('read') >= 0 || lname === 'glob' || lname === 'grep') {
if (fp && !changedFiles[fp]) readFiles[fp] = 1;
} else if (lname === 'bash' || lname.indexOf('shell') >= 0) {
} else if (lname === 'terminal' || lname.indexOf('shell') >= 0) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The viewer now only recognizes shell tools whose span name is terminal (or contains shell), which drops the legacy bash name it previously matched. Existing saved trace files recorded before this rename store shell-tool spans as bash, so after this change shell commands in those historical traces will silently stop being extracted: they will no longer appear in the shell-command list, dbt detection, command-outcome summaries, or the 'Ran N shell command(s)' line in the markdown summary. This is the same backward-compat concern the PR addressed for config permissions with a bash->terminal remap, so consider also accepting the legacy bash name when classifying spans here (e.g. lname === 'terminal' || lname === 'bash' || lname.indexOf('shell') >= 0 in all three branches), so historical traces keep rendering correctly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/altimate/observability/viewer.ts, line 598:

<comment>The viewer now only recognizes shell tools whose span name is `terminal` (or contains `shell`), which drops the legacy `bash` name it previously matched. Existing saved trace files recorded before this rename store shell-tool spans as `bash`, so after this change shell commands in those historical traces will silently stop being extracted: they will no longer appear in the shell-command list, dbt detection, command-outcome summaries, or the 'Ran N shell command(s)' line in the markdown summary. This is the same backward-compat concern the PR addressed for config permissions with a `bash`->`terminal` remap, so consider also accepting the legacy `bash` name when classifying spans here (e.g. `lname === 'terminal' || lname === 'bash' || lname.indexOf('shell') >= 0` in all three branches), so historical traces keep rendering correctly.</comment>

<file context>
@@ -595,7 +595,7 @@ function showDetail(span) {
     } else if (lname.indexOf('read') >= 0 || lname === 'glob' || lname === 'grep') {
       if (fp && !changedFiles[fp]) readFiles[fp] = 1;
-    } else if (lname === 'bash' || lname.indexOf('shell') >= 0) {
+    } else if (lname === 'terminal' || lname.indexOf('shell') >= 0) {
       var cmd = inpObj ? (inpObj.command || '') : (typeof inp === 'string' ? inp : '');
       if (cmd) {
</file context>

var cmd = inpObj ? (inpObj.command || '') : (typeof inp === 'string' ? inp : '');
if (cmd) {
// Extract the meaningful command — strip cd prefixes, take last command in chain
Expand Down Expand Up @@ -652,7 +652,7 @@ function showDetail(span) {
}

// For bash/shell commands — extract meaningful command and its outcome
if ((lname === 'bash' || lname.indexOf('shell') >= 0) && outStr) {
if ((lname === 'terminal' || lname.indexOf('shell') >= 0) && outStr) {
var rawCmd = String(inp.command || '');
var cmdParts = rawCmd.split(/\\s*&&\\s*/);
var displayCmd = cmdParts[cmdParts.length - 1].trim();
Expand Down Expand Up @@ -1475,7 +1475,7 @@ function showDetail(span) {
var fp = inp.file_path || inp.filePath || inp.path || null;
if (nm.indexOf('write') >= 0 || nm.indexOf('edit') >= 0) { if (fp) mdChanged[fp] = nm.indexOf('write') >= 0 ? 'new' : 'edited'; }
else if (nm.indexOf('read') >= 0) { mdReadCount++; }
else if (nm === 'bash') {
else if (nm === 'terminal' || nm.indexOf('shell') >= 0) {
var cmd = inp.command || '';
var parts = cmd.split(/\\s*&&\\s*/);
var last = parts[parts.length - 1].trim().toLowerCase();
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1429,7 +1429,7 @@ export namespace Telemetry {
return JSON.stringify({ "...": `${Object.keys(masked).length} keys` })
}

const FILE_TOOLS = new Set(["read", "write", "edit", "glob", "grep", "bash"])
const FILE_TOOLS = new Set(["read", "write", "edit", "glob", "grep", "terminal"])

// Order matters: more specific patterns (e.g. "warehouse_usage") are checked
// before broader ones (e.g. "warehouse") to avoid miscategorization.
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/altimate/tool-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export type RegistryToolOrigin = "native" | "altimate" | "external"
const NATIVE_TOOL_IDS = new Set<string>([
"invalid",
"question",
"bash",
"terminal",
"batch",
"read",
"glob",
Expand Down
Loading
Loading