Skip to content

Commit 7e16685

Browse files
authored
Merge pull request #218 from aictrl-dev/fix/issue-212-session-start-permissions
fix(run): emit the evaluated ruleset in session_start.permissions (#212)
2 parents e00f492 + ef3c37e commit 7e16685

5 files changed

Lines changed: 219 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
- **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)
1212
- **Retry waits are bounded for every status** — No single wait between retries exceeds 5 minutes. A retryable error of any status whose `Retry-After` / `retry-after-ms` asks for longer is no longer retried: a 429 still fails as `usage_limit` (#174), and any other status (for example a 503 or 529 asking for `retry-after: 18000`, which used to sleep about 5 hours per attempt) fails at once with the provider error, `session_error.reason` from its status (`provider` for a 5xx) and exit 1, with no `retry` event. Exponential backoff without a retry hint now stops at 30 s even when the response has headers, as every AI SDK HTTP error does, so a hintless 429 or 5xx retries for about 3.5 minutes in all instead of about 34. A zero or negative `Retry-After` now counts as no hint and backs off, instead of retrying with no wait. With 10 retries the waits total at most 50 minutes. (closes #175)
13+
- **`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).
1314

1415
### Internal
1516

‎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 agent's own `agent.<name>.permission` rules (the `--agent` or default agent; with `--command`, the command's `agent` unless the command runs as a subtask), 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 the agent the turn runs under cannot be resolved: an unknown default agent, or with `--command`, a command whose `agent` does not resolve (the run then fails with `session_error`).
102103

103104
### `tool_catalog`
104105

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

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { PermissionNext } from "../../permission/next"
2424
import { Session } from "../../session"
2525
import type { MessageV2 } from "../../session/message-v2"
2626
import { SessionPrompt } from "../../session/prompt"
27+
import { Command } from "../../command"
2728
import { GlobalBus } from "../../bus/global"
2829
import { Instance } from "../../project/instance"
2930
import { Tool } from "../../tool/tool"
@@ -325,6 +326,41 @@ export function sessionTitle(input: {
325326
return truncatedTitle(readable)
326327
}
327328

329+
/**
330+
* The agent the primary session's turn runs under, or undefined when it cannot
331+
* be resolved. SessionPrompt.command runs a command under its own agent unless
332+
* the command runs as a subtask, and fails the run when that agent is unknown.
333+
*/
334+
async function promptAgent(agent: string | undefined, command: string | undefined) {
335+
const commandInfo = command ? await Command.get(command) : undefined
336+
if (commandInfo?.agent) {
337+
const commandAgent = await Agent.get(commandInfo.agent)
338+
if (!commandAgent) return undefined
339+
if (!SessionPrompt.runsAsSubtask(commandInfo, commandAgent)) return commandAgent
340+
}
341+
return Agent.get(agent ?? (await Agent.defaultAgent()))
342+
}
343+
344+
/**
345+
* The ruleset this session's tool calls are evaluated against, in evaluation
346+
* order (the last matching rule wins): the agent's rules, which already include
347+
* the config `permission` rules, followed by the session's own rules — the same
348+
* merge SessionPrompt and LLM.resolveTools apply. An unknown session, default
349+
* agent or command agent yields no rules: the prompt then fails with a
350+
* session_error before any tool call runs.
351+
*/
352+
async function sessionPermissions(sessionID: string, agent: string | undefined, command: string | undefined) {
353+
try {
354+
const info = await promptAgent(agent, command)
355+
if (!info) return []
356+
const session = await Session.get(sessionID)
357+
return PermissionNext.merge(info.permission, session.permission ?? [])
358+
} catch (error) {
359+
Log.Default.warn("session_start permissions unresolved", { error })
360+
return []
361+
}
362+
}
363+
328364
export const RunCommand = cmd({
329365
command: "run [message..]",
330366
describe: "run aictrl with a message",
@@ -942,28 +978,27 @@ export const RunCommand = cmd({
942978
}
943979

944980
// Validate agent if specified
945-
const agentInfo = await (async () => {
946-
if (!args.agent) return { name: undefined, permission: [] as PermissionNext.Ruleset }
981+
const agent = await (async () => {
982+
if (!args.agent) return undefined
947983
const entry = await Agent.get(args.agent)
948984
if (!entry) {
949985
UI.println(
950986
UI.Style.TEXT_WARNING_BOLD + "!",
951987
UI.Style.TEXT_NORMAL,
952988
`agent "${args.agent}" not found. Falling back to default agent`,
953989
)
954-
return { name: undefined, permission: [] as PermissionNext.Ruleset }
990+
return undefined
955991
}
956992
if (entry.mode === "subagent") {
957993
UI.println(
958994
UI.Style.TEXT_WARNING_BOLD + "!",
959995
UI.Style.TEXT_NORMAL,
960996
`agent "${args.agent}" is a subagent, not a primary agent. Falling back to default agent`,
961997
)
962-
return { name: undefined, permission: [] as PermissionNext.Ruleset }
998+
return undefined
963999
}
964-
return { name: args.agent, permission: (entry.permission ?? []) as PermissionNext.Ruleset }
1000+
return args.agent
9651001
})()
966-
const agent = agentInfo.name
9671002

9681003
invocation.phase("session")
9691004
const sessionID = await session(sdk)
@@ -977,7 +1012,7 @@ export const RunCommand = cmd({
9771012
schemaVersion: SCHEMA_VERSION,
9781013
model: args.model,
9791014
agent: agent,
980-
permissions: agentInfo.permission,
1015+
permissions: args.attach ? [] : await sessionPermissions(sessionID, agent, args.command),
9811016
})
9821017

9831018
let aborted = false

‎packages/cli/src/session/prompt.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1526,6 +1526,11 @@ export namespace SessionPrompt {
15261526
* Does not match when preceded by word characters or backticks (to avoid email addresses and quoted references)
15271527
*/
15281528

1529+
/** Whether a command runs as a subtask in a child session instead of in the calling session. */
1530+
export function runsAsSubtask(command: Pick<Command.Info, "subtask">, agent: Pick<Agent.Info, "mode">) {
1531+
return (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
1532+
}
1533+
15291534
export async function command(input: CommandInput) {
15301535
log.info("command", input)
15311536
const command = await Command.get(input.command)
@@ -1606,7 +1611,7 @@ export namespace SessionPrompt {
16061611
const agent = await requireAgent(agentName, input.sessionID)
16071612

16081613
const templateParts = await resolvePromptParts(template)
1609-
const isSubtask = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true
1614+
const isSubtask = runsAsSubtask(command, agent)
16101615
const parts = isSubtask
16111616
? [
16121617
{
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
type RunInput = { config: Record<string, unknown>; args?: string[] }
13+
14+
/** Runs `aictrl run --format json` against a fixture provider that answers with text, and returns its NDJSON events. */
15+
async function runEvents(input: RunInput) {
16+
await using tmp = await tmpdir({ git: true })
17+
const server = Bun.serve({
18+
hostname: "127.0.0.1",
19+
port: 0,
20+
idleTimeout: 0,
21+
fetch() {
22+
const chunks = [
23+
{ delta: { role: "assistant", content: "Fixture complete" }, finish_reason: null },
24+
{ delta: {}, finish_reason: "stop" },
25+
].map(
26+
(choice) =>
27+
`data: ${JSON.stringify({ id: "fixture", object: "chat.completion.chunk", created: 1, model: "fixture", choices: [{ index: 0, ...choice }] })}\n\n`,
28+
)
29+
return new Response(chunks.join("") + "data: [DONE]\n\n", {
30+
headers: { "content-type": "text/event-stream" },
31+
})
32+
},
33+
})
34+
const config = {
35+
provider: {
36+
fixture: {
37+
npm: "@ai-sdk/openai-compatible",
38+
options: { apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}/v1` },
39+
models: { fixture: { name: "fixture", tool_call: true, limit: { context: 100000, output: 1000 } } },
40+
},
41+
},
42+
...input.config,
43+
}
44+
const home = path.join(tmp.path, "home")
45+
const proc = Bun.spawn(
46+
[...command, "run", "--format", "json", "--model", "fixture/fixture", ...(input.args ?? []), "Say done."],
47+
{
48+
cwd: tmp.path,
49+
env: {
50+
...process.env,
51+
AICTRL_CONFIG_CONTENT: JSON.stringify(config),
52+
AICTRL_DISABLE_DEFAULT_PLUGINS: "true",
53+
AICTRL_DISABLE_AUTOCOMPACT: "true",
54+
HOME: home,
55+
AICTRL_TEST_HOME: home,
56+
AICTRL_TEST_MANAGED_CONFIG_DIR: path.join(tmp.path, "managed"),
57+
XDG_DATA_HOME: path.join(tmp.path, "data"),
58+
XDG_CONFIG_HOME: path.join(tmp.path, "config"),
59+
XDG_STATE_HOME: path.join(tmp.path, "state"),
60+
XDG_CACHE_HOME: path.join(tmp.path, "cache"),
61+
},
62+
stdout: "pipe",
63+
stderr: "pipe",
64+
},
65+
)
66+
const timeout = setTimeout(() => proc.kill("SIGKILL"), 15000)
67+
try {
68+
const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
69+
const events: { type: string }[] = stdout
70+
.split("\n")
71+
.filter((line) => line.startsWith("{"))
72+
.map((line) => JSON.parse(line))
73+
if (!events.some((event) => event.type === "session_start"))
74+
throw new Error(`no session_start event\n${stderr}\n${stdout}`)
75+
return events
76+
} finally {
77+
clearTimeout(timeout)
78+
proc.kill("SIGKILL")
79+
server.stop(true)
80+
}
81+
}
82+
83+
type SessionStart = { type: "session_start"; agent?: string; permissions: Rule[] }
84+
85+
function findSessionStart(events: { type: string }[]) {
86+
return events.find((event): event is SessionStart => event.type === "session_start")!
87+
}
88+
89+
async function sessionStart(input: RunInput) {
90+
return findSessionStart(await runEvents(input))
91+
}
92+
93+
/** The rule a tool call is decided by: the last rule whose permission and pattern match (exact match is enough here). */
94+
function decisive(rules: Rule[], permission: string, pattern: string) {
95+
return rules.findLast(
96+
(rule) =>
97+
(rule.permission === "*" || rule.permission === permission) && (rule.pattern === "*" || rule.pattern === pattern),
98+
)
99+
}
100+
101+
describe("session_start.permissions (#212)", () => {
102+
test("lists config rules for the default agent, in evaluation order", async () => {
103+
const event = await sessionStart({
104+
config: { agent: { title: { disable: true } }, permission: { "*": "allow", bash: { "rm *": "deny" } } },
105+
})
106+
107+
expect(event.permissions).toContainEqual({ permission: "bash", pattern: "rm *", action: "deny" })
108+
expect(decisive(event.permissions, "bash", "rm *")?.action).toBe("deny")
109+
expect(decisive(event.permissions, "bash", "ls")?.action).toBe("allow")
110+
})
111+
112+
test("places a selected agent's own rules after the config rules", async () => {
113+
const event = await sessionStart({
114+
args: ["--agent", "reviewer"],
115+
config: {
116+
agent: { title: { disable: true }, reviewer: { mode: "primary", permission: { bash: { "rm *": "allow" } } } },
117+
permission: { "*": "allow", bash: { "rm *": "deny" } },
118+
},
119+
})
120+
121+
expect(event.agent).toBe("reviewer")
122+
expect(decisive(event.permissions, "bash", "rm *")?.action).toBe("allow")
123+
})
124+
125+
test("uses a --command's own agent, which the command's tool calls run under", async () => {
126+
const config = {
127+
agent: { title: { disable: true }, reviewer: { mode: "primary", permission: { bash: { "rm *": "allow" } } } },
128+
permission: { "*": "allow", bash: { "rm *": "deny" } },
129+
}
130+
const inline = await sessionStart({
131+
args: ["--command", "review"],
132+
config: { ...config, command: { review: { template: "Review $ARGUMENTS", agent: "reviewer" } } },
133+
})
134+
// A subtask command runs in a child session; the primary session keeps the default agent.
135+
const subtask = await sessionStart({
136+
args: ["--command", "review"],
137+
config: { ...config, command: { review: { template: "Review $ARGUMENTS", agent: "reviewer", subtask: true } } },
138+
})
139+
140+
expect(decisive(inline.permissions, "bash", "rm *")?.action).toBe("allow")
141+
expect(decisive(subtask.permissions, "bash", "rm *")?.action).toBe("deny")
142+
})
143+
144+
test("is still emitted when the default agent cannot be resolved", async () => {
145+
// The prompt reports the unknown agent as session_error; session_start must not be lost.
146+
const event = await sessionStart({ config: { default_agent: "missing", agent: { title: { disable: true } } } })
147+
148+
expect(event.permissions).toEqual([])
149+
})
150+
151+
test("is empty when a --command's agent cannot be resolved, and the run fails", async () => {
152+
// SessionPrompt.command runs the command under its own agent, so an unknown one fails the run
153+
// instead of falling back to the default agent, whose rules would then never be evaluated.
154+
const events = await runEvents({
155+
args: ["--command", "review"],
156+
config: {
157+
agent: { title: { disable: true } },
158+
permission: { "*": "allow" },
159+
command: { review: { template: "Review $ARGUMENTS", agent: "missing" } },
160+
},
161+
})
162+
163+
expect(findSessionStart(events).permissions).toEqual([])
164+
expect(events.map((event) => event.type)).toContain("session_error")
165+
})
166+
})

0 commit comments

Comments
 (0)