Skip to content
Closed
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
29 changes: 24 additions & 5 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,12 +143,12 @@ Aictrl reads config from `.aictrl/` (project) or `~/.config/aictrl/` (global).

### Models

| Provider | Env Var |
|----------|---------|
| Anthropic | `ANTHROPIC_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
| Provider | Env Var |
| ---------------- | -------------------------------------------------------------------- |
| Anthropic | `ANTHROPIC_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
| Google AI Studio | `GOOGLE_GENERATIVE_AI_API_KEY`, `GEMINI_API_KEY` or `GOOGLE_API_KEY` |
| OpenRouter | `OPENROUTER_API_KEY` |
| OpenRouter | `OPENROUTER_API_KEY` |

Use `--variant` to select provider-specific reasoning effort. For OpenAI-compatible
Ollama models, `--variant none` sends `reasoning_effort: "none"` to disable
Expand Down Expand Up @@ -194,6 +194,25 @@ If your agents should commit or open PRs, supply your own procedure through a
skill, `AGENTS.md` or the `instructions` config, and run the CLI in an isolated
environment with credentials scoped to what the task needs.

## Project instruction isolation

Set `AICTRL_DISABLE_PROJECT_CONFIG=true` to disable automatic project instruction
discovery as well as project configuration. This excludes root `AGENTS.md`,
`.opencode/AGENTS.md`, and nested `AGENTS.md` files that would otherwise be
attached when a tool reads a project file.

Trusted global instruction files (`~/.config/aictrl/AGENTS.md` and
`AICTRL_CONFIG_DIR/AGENTS.md`) and explicit `instructions` entries in caller
configuration remain available. Relative caller instructions resolve from
`AICTRL_CONFIG_DIR` when project configuration is disabled; without that directory,
relative entries are skipped. Absolute paths and URLs remain explicit inputs.

This controls automatic instruction discovery. Project files and tool output
remain untrusted task data, so CI jobs still need scoped credentials, configured
permissions, and process isolation. Keep any existing instruction-file deletion
workaround until the installed release includes this behavior and has been
verified.

## Local Development

```bash
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/session/instruction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ function globalFiles() {
files.push(path.join(Flag.AICTRL_CONFIG_DIR, "AGENTS.md"))
}
files.push(path.join(Global.Path.config, "AGENTS.md"))
files.push(path.join(Instance.directory, ".opencode", "AGENTS.md"))
if (!Flag.AICTRL_DISABLE_PROJECT_CONFIG) {
files.push(path.join(Instance.directory, ".opencode", "AGENTS.md"))
}
return files
}

Expand Down Expand Up @@ -181,6 +183,7 @@ export namespace InstructionPrompt {
}

export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) {
if (Flag.AICTRL_DISABLE_PROJECT_CONFIG) return []
const system = await systemPaths()
const already = loaded(messages)
const results: { filepath: string; content: string }[] = []
Expand Down
157 changes: 157 additions & 0 deletions packages/cli/test/session/instruction-isolation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import path from "path"
import { InstructionPrompt } from "../../src/session/instruction"
import { Instance } from "../../src/project/instance"
import { Global } from "../../src/global"
import { HeadlessRun } from "../../src/flag/headless-run"
import { ReadTool } from "../../src/tool/read"
import { tmpdir } from "../fixture/fixture"

describe("project instruction isolation (#250)", () => {
const keys = ["AICTRL_DISABLE_PROJECT_CONFIG", "AICTRL_CONFIG_DIR", "AICTRL_CONFIG_CONTENT"] as const
let env: (string | undefined)[]
let config: string
let headless: boolean

beforeEach(() => {
env = keys.map((key) => process.env[key])
config = Global.Path.config
headless = HeadlessRun.enabled()
for (const key of keys) delete process.env[key]
})

afterEach(() => {
keys.forEach((key, index) => {
if (env[index] === undefined) delete process.env[key]
else process.env[key] = env[index]
})
Global.Path.config = config
if (headless) HeadlessRun.enable()
else HeadlessRun.disable()
})

test.each([false, true])("disabled=%s controls root and .opencode automatic instructions", async (disabled) => {
await using global = await tmpdir()
Global.Path.config = global.path
if (disabled) process.env.AICTRL_DISABLE_PROJECT_CONFIG = "true"
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "ROOT_ISOLATION_MARKER")
await Bun.write(path.join(dir, ".opencode", "AGENTS.md"), "OPENCODE_ISOLATION_MARKER")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const paths = await InstructionPrompt.systemPaths()
expect(paths.has(path.join(tmp.path, "AGENTS.md"))).toBe(!disabled)
expect(paths.has(path.join(tmp.path, ".opencode", "AGENTS.md"))).toBe(!disabled)
const instructions = (await InstructionPrompt.system()).join("\n")
expect(instructions.includes("ROOT_ISOLATION_MARKER")).toBe(!disabled)
expect(instructions.includes("OPENCODE_ISOLATION_MARKER")).toBe(!disabled)
},
})
})

test.each([
[false, "resolve"],
[true, "resolve"],
[false, "read"],
[true, "read"],
] as const)("disabled=%s controls nested %s instructions", async (disabled, mode) => {
await using global = await tmpdir()
Global.Path.config = global.path
if (disabled) process.env.AICTRL_DISABLE_PROJECT_CONFIG = "true"
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "nested", "AGENTS.md"), "NESTED_ISOLATION_MARKER")
await Bun.write(path.join(dir, "nested", "file.txt"), "ordinary file content")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const filepath = path.join(tmp.path, "nested", "file.txt")
if (mode === "resolve") {
const results = await InstructionPrompt.resolve([], filepath, "resolve-isolation")
expect(
results
.map((item) => item.content)
.join("\n")
.includes("NESTED_ISOLATION_MARKER"),
).toBe(!disabled)
return
}
const read = await ReadTool.init()
const result = await read.execute(
{ filePath: filepath },
{
sessionID: "isolation-test",
messageID: "read-isolation",
callID: "read-isolation",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
},
)
expect(result.output).toContain("ordinary file content")
expect(result.output.includes("NESTED_ISOLATION_MARKER")).toBe(!disabled)
expect(result.output.includes("<system-reminder>")).toBe(!disabled)
expect(result.metadata.loaded).toEqual(disabled ? [] : [path.join(tmp.path, "nested", "AGENTS.md")])
},
})
})

test.each([false, true])(
"headless=%s retains trusted and explicit caller instructions when disabled",
async (active) => {
if (active) HeadlessRun.enable()
else HeadlessRun.disable()
process.env.AICTRL_DISABLE_PROJECT_CONFIG = "true"
await using global = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "GLOBAL_ISOLATION_MARKER")
},
})
Global.Path.config = global.path
await using profile = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "PROFILE_ISOLATION_MARKER")
await Bun.write(path.join(dir, "relative.md"), "RELATIVE_ISOLATION_MARKER")
},
})
await using caller = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "caller.md"), "CALLER_ISOLATION_MARKER")
},
})
process.env.AICTRL_CONFIG_CONTENT = JSON.stringify({ instructions: [path.join(caller.path, "caller.md")] })
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const instructions = (await InstructionPrompt.system()).join("\n")
expect(instructions).toContain("GLOBAL_ISOLATION_MARKER")
expect(instructions).toContain("CALLER_ISOLATION_MARKER")
},
})
process.env.AICTRL_CONFIG_DIR = profile.path
process.env.AICTRL_CONFIG_CONTENT = JSON.stringify({
instructions: ["relative.md", path.join(caller.path, "caller.md")],
})
await using other = await tmpdir()
await Instance.provide({
directory: other.path,
fn: async () => {
const instructions = (await InstructionPrompt.system()).join("\n")
expect(instructions).toContain("PROFILE_ISOLATION_MARKER")
expect(instructions).not.toContain("GLOBAL_ISOLATION_MARKER")
expect(instructions).toContain("RELATIVE_ISOLATION_MARKER")
expect(instructions).toContain("CALLER_ISOLATION_MARKER")
},
})
},
)
})
Loading