Skip to content

Commit 03105a0

Browse files
byapparovclaude
andcommitted
fix(run): emit the evaluated ruleset in session_start.permissions (#212)
session_start.permissions reported only an explicitly selected agent's rules: it was empty for the default agent and never included session rules, so a config `permission.bash: {"rm *": "deny"}` did not appear. Emit the same merge SessionPrompt and LLM.resolveTools evaluate tool calls against — the resolved agent's rules (built-in defaults, config `permission`, agent overrides) followed by the session's rules — in evaluation order, last match wins. An unknown session or default agent still yields [] so session_start is emitted before the prompt reports the failure as session_error. --attach keeps [] (the attached server owns the ruleset). EVENTS.md documents the order. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_015nwvfUHdM3bgMgD9Zpz7Mj
1 parent d1fb902 commit 03105a0

4 files changed

Lines changed: 148 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixes
66

77
- **Messages written in the same millisecond keep their order** — Reading a session's messages now breaks `time_created` ties by message ID. Before, messages written in the same millisecond came back oldest first. A compaction writes its request, its summary and the continue message in quick succession, so the prompt loop could miss the finished summary and compact again, sending an extra model request. (#200)
8+
- **`session_start.permissions` is complete** — it now lists the full ruleset tool calls are evaluated against (agent defaults, config `permission` rules, agent rules and session rules) in evaluation order; it was empty for the default agent and omitted session rules (#212).
89

910
## 0.5.1 (2026-10-18)
1011

‎EVENTS.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,15 @@ Emitted once when the session begins.
9191
"model": "anthropic/claude-sonnet-4-20250514",
9292
"agent": "default",
9393
"permissions": [
94-
{ "permission": "bash", "pattern": "*", "action": "allow" },
95-
{ "permission": "write", "pattern": "*", "action": "ask" }
94+
{ "permission": "*", "pattern": "*", "action": "allow" },
95+
{ "permission": "edit", "pattern": "*aictrl.json", "action": "ask" },
96+
{ "permission": "bash", "pattern": "rm *", "action": "deny" }
9697
]
9798
}
9899
```
99100

100101
- `schemaVersion` (string, **required**) — pinned contract version. Currently `"1"`.
101-
- `permissions` (array, **required**) — the fully resolved permission ruleset for the session (merge of agent + session rules). In headless mode, any permission not matching an `allow` rule is auto-rejected.
102+
- `permissions` (array, **required**) — the ruleset this session's tool calls are evaluated against, in evaluation order: the agent's built-in defaults, then the config `permission` rules, then the selected agent's own `agent.<name>.permission` rules, then the session's rules. The **last** rule whose `permission` and `pattern` match a call decides it, so a later rule overrides an earlier one. In headless mode, any call not decided by an `allow` rule is rejected. Empty with `--attach`, where the attached server owns the ruleset, and when the session or default agent cannot be resolved (the run then fails with `session_error`).
102103

103104
### `tool_catalog`
104105

‎packages/cli/src/cli/cmd/run.ts‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,24 @@ export function sessionTitle(input: {
324324
return truncatedTitle(readable)
325325
}
326326

327+
/**
328+
* The ruleset this session's tool calls are evaluated against, in evaluation
329+
* order (the last matching rule wins): the agent's rules, which already include
330+
* the config `permission` rules, followed by the session's own rules — the same
331+
* merge SessionPrompt and LLM.resolveTools apply. An unknown session or
332+
* default agent yields no rules: the prompt then fails with a session_error
333+
* before any tool call runs.
334+
*/
335+
async function sessionPermissions(sessionID: string, agent: string | undefined) {
336+
try {
337+
const info = await Agent.get(agent ?? (await Agent.defaultAgent()))
338+
const session = await Session.get(sessionID)
339+
return PermissionNext.merge(info.permission, session.permission ?? [])
340+
} catch {
341+
return []
342+
}
343+
}
344+
327345
export const RunCommand = cmd({
328346
command: "run [message..]",
329347
describe: "run aictrl with a message",
@@ -931,28 +949,27 @@ export const RunCommand = cmd({
931949
}
932950

933951
// Validate agent if specified
934-
const agentInfo = await (async () => {
935-
if (!args.agent) return { name: undefined, permission: [] as PermissionNext.Ruleset }
952+
const agent = await (async () => {
953+
if (!args.agent) return undefined
936954
const entry = await Agent.get(args.agent)
937955
if (!entry) {
938956
UI.println(
939957
UI.Style.TEXT_WARNING_BOLD + "!",
940958
UI.Style.TEXT_NORMAL,
941959
`agent "${args.agent}" not found. Falling back to default agent`,
942960
)
943-
return { name: undefined, permission: [] as PermissionNext.Ruleset }
961+
return undefined
944962
}
945963
if (entry.mode === "subagent") {
946964
UI.println(
947965
UI.Style.TEXT_WARNING_BOLD + "!",
948966
UI.Style.TEXT_NORMAL,
949967
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
950968
)
951-
return { name: undefined, permission: [] as PermissionNext.Ruleset }
969+
return undefined
952970
}
953-
return { name: args.agent, permission: (entry.permission ?? []) as PermissionNext.Ruleset }
971+
return args.agent
954972
})()
955-
const agent = agentInfo.name
956973

957974
invocation.phase("session")
958975
const sessionID = await session(sdk)
@@ -965,7 +982,7 @@ export const RunCommand = cmd({
965982
schemaVersion: SCHEMA_VERSION,
966983
model: args.model,
967984
agent: agent,
968-
permissions: agentInfo.permission,
985+
permissions: args.attach ? [] : await sessionPermissions(sessionID, agent),
969986
})
970987

971988
let aborted = false
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, expect, test } from "bun:test"
2+
import path from "path"
3+
import { tmpdir } from "../fixture/fixture"
4+
5+
const entry = path.resolve(import.meta.dir, "../../src/index.ts")
6+
const command = process.env.AICTRL_TEST_BINARY
7+
? [process.env.AICTRL_TEST_BINARY]
8+
: ["bun", "run", "--conditions=browser", entry]
9+
10+
type Rule = { permission: string; pattern: string; action: string }
11+
12+
/** Runs `aictrl run --format json` against a fixture provider that answers with text, and returns `session_start`. */
13+
async function sessionStart(input: { config: Record<string, unknown>; args?: string[] }) {
14+
await using tmp = await tmpdir({ git: true })
15+
const server = Bun.serve({
16+
hostname: "127.0.0.1",
17+
port: 0,
18+
idleTimeout: 0,
19+
fetch() {
20+
const chunks = [
21+
{ delta: { role: "assistant", content: "Fixture complete" }, finish_reason: null },
22+
{ delta: {}, finish_reason: "stop" },
23+
].map(
24+
(choice) =>
25+
`data: ${JSON.stringify({ id: "fixture", object: "chat.completion.chunk", created: 1, model: "fixture", choices: [{ index: 0, ...choice }] })}\n\n`,
26+
)
27+
return new Response(chunks.join("") + "data: [DONE]\n\n", {
28+
headers: { "content-type": "text/event-stream" },
29+
})
30+
},
31+
})
32+
const config = {
33+
provider: {
34+
fixture: {
35+
npm: "@ai-sdk/openai-compatible",
36+
options: { apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}/v1` },
37+
models: { fixture: { name: "fixture", tool_call: true, limit: { context: 100000, output: 1000 } } },
38+
},
39+
},
40+
...input.config,
41+
}
42+
const home = path.join(tmp.path, "home")
43+
const proc = Bun.spawn(
44+
[...command, "run", "--format", "json", "--model", "fixture/fixture", ...(input.args ?? []), "Say done."],
45+
{
46+
cwd: tmp.path,
47+
env: {
48+
...process.env,
49+
AICTRL_CONFIG_CONTENT: JSON.stringify(config),
50+
AICTRL_DISABLE_DEFAULT_PLUGINS: "true",
51+
AICTRL_DISABLE_AUTOCOMPACT: "true",
52+
HOME: home,
53+
AICTRL_TEST_HOME: home,
54+
AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(tmp.path, "managed"),
55+
XDG_DATA_HOME: path.join(tmp.path, "data"),
56+
XDG_CONFIG_HOME: path.join(tmp.path, "config"),
57+
XDG_STATE_HOME: path.join(tmp.path, "state"),
58+
XDG_CACHE_HOME: path.join(tmp.path, "cache"),
59+
},
60+
stdout: "pipe",
61+
stderr: "pipe",
62+
},
63+
)
64+
const timeout = setTimeout(() => proc.kill("SIGKILL"), 15000)
65+
try {
66+
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
67+
const event = stdout
68+
.split("\n")
69+
.filter((line) => line.startsWith("{"))
70+
.map((line) => JSON.parse(line))
71+
.find((event) => event.type === "session_start")
72+
if (!event) throw new Error(`no session_start event\n${stderr}\n${stdout}`)
73+
return event as { agent?: string; permissions: Rule[] }
74+
} finally {
75+
clearTimeout(timeout)
76+
proc.kill("SIGKILL")
77+
server.stop(true)
78+
}
79+
}
80+
81+
/** The rule a tool call is decided by: the last rule whose permission and pattern match (exact match is enough here). */
82+
function decisive(rules: Rule[], permission: string, pattern: string) {
83+
return rules.findLast(
84+
(rule) =>
85+
(rule.permission === "*" || rule.permission === permission) && (rule.pattern === "*" || rule.pattern === pattern),
86+
)
87+
}
88+
89+
describe("session_start.permissions (#212)", () => {
90+
test("lists config rules for the default agent, in evaluation order", async () => {
91+
const event = await sessionStart({
92+
config: { agent: { title: { disable: true } }, permission: { "*": "allow", bash: { "rm *": "deny" } } },
93+
})
94+
95+
expect(event.permissions).toContainEqual({ permission: "bash", pattern: "rm *", action: "deny" })
96+
expect(decisive(event.permissions, "bash", "rm *")?.action).toBe("deny")
97+
expect(decisive(event.permissions, "bash", "ls")?.action).toBe("allow")
98+
})
99+
100+
test("places a selected agent's own rules after the config rules", async () => {
101+
const event = await sessionStart({
102+
args: ["--agent", "reviewer"],
103+
config: {
104+
agent: { title: { disable: true }, reviewer: { mode: "primary", permission: { bash: { "rm *": "allow" } } } },
105+
permission: { "*": "allow", bash: { "rm *": "deny" } },
106+
},
107+
})
108+
109+
expect(event.agent).toBe("reviewer")
110+
expect(decisive(event.permissions, "bash", "rm *")?.action).toBe("allow")
111+
})
112+
113+
test("is still emitted when the default agent cannot be resolved", async () => {
114+
// The prompt reports the unknown agent as session_error; session_start must not be lost.
115+
const event = await sessionStart({ config: { default_agent: "missing", agent: { title: { disable: true } } } })
116+
117+
expect(event.permissions).toEqual([])
118+
})
119+
})

0 commit comments

Comments
 (0)