fix(composition): preserve edit authority and mandatory safety policy

Problem: ungranted HTTP clients could mutate presets or switch compositions,
and empty hook selections could remove configured safety guards. A paired
remote pane could also borrow the host credential, and a selected native
extension could override the host's denied read.

Root cause: preset routes bypassed request-scoped edit grants, proxying erased
device authority, and frozen automation was treated as the whole safety policy.

Fix: gate preset mutations before store/admission access, refuse paired preset
writes before remote credential forwarding, overlay current authorized guards
outside retained automation, update their shared runner on live edits, resolve
hook inspection/save/approval/reload against one home, and mark bound runtime
packages tighten-only. Preserve ordinary unbound extension behavior, immutable
bindings, optional retained automation and the accepted Standard/Minimal UI.

Verification: failing-first HTTP, proxy and real boot/provider regressions;
format, vet, lint, make check, full root and required builtin/boot suites;
affected package and focused boot races; Desktop/SDK; frontend 689 files/5598
tests, typecheck/build and census; browser eight/Electron renderer four scenes;
Electron units. Correct an inherited mod fixture's premature teardown using
the existing durable-idle notification, with 20 independent process runs.
No test threshold, approval gate or product Close behavior is weakened.
This commit is contained in:
SivanCola committed 2026-10-18 22:03:15 +08:00
1 parent db1401cb5b
commit 38609899ae
20 files changed
+648 -24

No files matched your search

@@ -65,6 +65,7 @@ export const EN_COMPOSITION: Record<string,string> = {
"参数需要有效的 JSON 对象": "Parameters must be a valid JSON object",
"添加模块": "Add module",
"自动化 Hook": "Automation hooks",
"安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。": "Safety hooks and permission rules always apply; this selection controls non-blocking automation only.",
"斜杠命令": "Slash commands",
"用其他组合新建会话": "Start a conversation with another composition",
"组合资源不可用,当前会话仅可查看历史。": "Composition resources are unavailable. This conversation is open for history only.",
@@ -41,6 +41,7 @@ describe("Agent preset management", () => {
expect(await port.agentComposition()).toBeNull();
await user.click(minimal);
await user.click(screen.getByRole("button", { name: "复制为自定义预设" }));
expect(screen.getByText("安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "保存预设" }));
await screen.findByRole("status");
const copied = (await port.agentPresets()).presets.find((v) => v.definition.id === "user:custom")!;
@@ -32,6 +32,7 @@ function ModuleEditor({ kind, choices,value, disabled, onChange, onValidityChang
};
return <fieldset className="composition-modules" disabled={disabled}>
<legend>{t(labels[kind])}</legend>
{kind === "hooks" && <p className="ds">{t("安全 Hook 和权限规则始终生效;此处仅选择非阻断型自动化。")}</p>}
<select aria-label={`${t(labels[kind])} ${t("选择方式")}`} data-action-change="agent-preset.module-mode" data-target={kind} value={value.mode} onChange={(e) => { validate({});setRawParameters({});onChange({ mode: e.target.value as "inherit" | "select", members: [] }); }}>
<option value="inherit">{t("创建会话时继承当前配置")}</option><option value="select">{t("仅使用所选项(空列表表示禁用)")}</option>
</select>
+34 -1
View File
@@ -121,7 +121,7 @@ readable as history but cannot execute, even with an old local authorization
record. It is not silently rewritten. Valid timeout-only project bindings
retain their original version.
Inherited choices resolve once at binding. Retained skill and hook files and
Inherited choices resolve once at binding. Retained skill and non-blocking hook files and
extension code do not follow subsequent source edits.
Hooks retain referenced files and sibling bundles for common script formats.
@@ -137,6 +137,28 @@ with the existing credential/configuration owner. Sessions use separate MCP
connections and process state, and extensions run from private copies. Existing
trust, revocation, authorization and sandbox checks still apply.
### Safety policy stays outside the composition
| Hook or rule | Can a composition deselect it? |
| --- | --- |
| `PreToolUse`, `UserPromptSubmit`, `PermissionRequest` hooks | No, in every scope, including imported Claude permission hooks |
| Permission `ask`/`deny` rules and remembered revocations | No; ordinary permission policy still judges the resolved call |
| Other Hook events | Yes; only selected non-blocking automation is retained |
Extensions selected by a bound composition are also tighten-only: neither a
permission interceptor nor a permission strategy may turn an `ask`/`deny` into
an allow. Ordinary unbound extension behavior keeps its existing contract.
Guards load from current authorized configuration at assembly and restore,
including for old bindings with an empty Hook selection. They also apply when
their installed package is outside the selected extension set.
Saving safety hooks updates the live shared runner, including child roles,
without replacing retained automation or the immutable composition digest.
An authorized policy edit may change a guard; selecting a composition cannot.
Project program approval and package activation still control which guards load.
A binding is local transcript metadata and never enters provider messages.
Only explicitly bound composition transcripts use native event-log schema 4; older readers refuse
them instead of continuing with another tool set. Legacy transcripts keep
@@ -212,6 +234,17 @@ send the selected composition ID to that host; local executable resources are no
| `POST /tree/sessions/import-native` | `{workspace, content}` → imported `{path, agentComposition}` |
| `POST /tree/sessions/move-to` | `{path, target}` → moved unbound conversation; bound conversations are refused |
Preset save/delete/default/import and session composition selection require the
request's provider-edit grant, just like configuration writes. Without it they
return HTTP 403 `config.editing_disabled` before store access or selection.
Paired devices do not inherit the host's grant. Catalog/detail/export and current
binding reads remain available.
The remote-pane proxy refuses paired-device preset mutations before forwarding
under the host's remote credential. Reads and the host's own requests still reach
the remote kernel, which checks its grant independently.
`/status` and runtime listings include `agentComposition`. Refusals use stable
`agent_composition.invalid`, `.not_found`, `.read_only`, `.conflict`, `.locked`
and `.unavailable` codes. The Studio catalogs translate these into Chinese and
+27 -1
View File
@@ -89,7 +89,7 @@ Hook 的项目程序授权与撤销检查继续生效;选择预设不等于批
此前保存的项目绑定若含启动参数覆盖,历史仍可读取,但不能继续执行,即使本机
保留了旧授权记录;不会静默改写为其他组合。合法的仅超时项目绑定继续保留原版本。
继承项只在绑定时解析一次。技能、Hook 和扩展代码使用保留副本,后续源文件
继承项只在绑定时解析一次。技能、非阻断型 Hook 和扩展代码使用保留副本,后续源文件
修改不会改写旧版本。凭据继续由原有凭据或配置机制提供。MCP 连接与进程状态
按会话隔离,扩展使用独立运行副本。原有信任、撤销、授权和沙箱规则继续生效。
@@ -97,6 +97,23 @@ Hook 保留直接引用的文件,并为常见脚本格式保留同目录依赖
大小限制,不能包含凭据文件或符号链接;建议将代码放在独立目录,凭据使用
环境引用。外部解释器和服务继续遵守原有平台及授权契约。
### 安全策略不属于可切换的组合
| Hook 或规则 | 能否由组合取消 |
| --- | --- |
| `PreToolUse`、`UserPromptSubmit`、`PermissionRequest` | 不能,所有来源均保留,包括导入的 Claude 权限 Hook |
| 权限 `ask`/`deny` 规则及已撤销授权 | 不能,解析后的实际调用仍由原权限策略判断 |
| 其他 Hook 事件 | 可以,只保留所选非阻断型自动化 |
绑定组合中的扩展也只能收紧权限:拦截器与权限策略均不能把 `ask`/`deny` 改为
允许。未绑定会话的原有扩展契约不变。
装配和恢复时从当前已授权配置加载安全 Hook,包括旧版本中空 Hook 选择的绑定。
已安装包的安全 Hook 不受组合的扩展选择影响;项目程序批准与包启用状态仍须满足。
在设置中保存安全 Hook 会立即更新会话和子角色共享的运行器,不替换保留的自动化,
也不改写组合摘要。用户有权编辑安全策略,选择组合不能移除它。
绑定只进入本地会话元数据,不发送给 provider。组合会话使用原生事件日志
schema 4,旧程序会明确拒绝。无绑定历史仍走兼容路径,不套用当前默认组合。
无法授权或装配原绑定时,可以只读查看历史,不能继续执行。删除声明不会
@@ -135,6 +152,15 @@ CLI 使用 `--agent-composition builtin:minimal` 或 `--agent-composition user:m
交给远端解析,不上传本机可执行资源。
HTTP 路由、请求字段与稳定错误代码见 [接口说明](AGENT_COMPOSITIONS.md#entry-points)。
保存、删除、默认选择、导入预设及切换会话组合均要求请求具有配置编辑授权。
缺少时返回 HTTP 403 `config.editing_disabled`,不写文件或进入切换流程。
配对设备
不继承宿主的授权。目录、详情、导出及当前绑定查询保持可读。
远端面板代理在使用宿主凭据转发前,先拒绝配对设备的预设写入请求。读取和宿主
自身的请求仍可转发,远端再独立检查其配置编辑授权。
`/status` 和会话运行时列表返回 `agentComposition`。导入返回定义与依赖诊断,
需要用户检查后另行保存。错误代码在 Studio 中提供中英文翻译。
@@ -179,7 +179,10 @@ func compositionPackages(home string, packages []pluginpkg.InstalledPackage, bin
return nil, fmt.Errorf("%w: extension has no configurable runtime", agentcomposition.ErrInvalid)
}
}
if p.Package.Manifest.Runtime != nil {
if rt := p.Package.Manifest.Runtime; rt != nil {
copy := *rt
copy.TightenOnly = true
p.Package.Manifest.Runtime = &copy
out = append(out, p)
}
}
@@ -100,12 +100,20 @@ func compositionHookSet(opts Options, home, workspace string) ([]hook.ResolvedHo
if err := jsonResource(r, &saved); err != nil {
return nil, err
}
if saved.Snapshot.Hook.IsPolicyGuard() {
continue
}
h, err := restoreCompositionHook(store, saved, workspace, byID[r.ID])
if err != nil {
return nil, err
}
out = append(out, h)
}
for _, h := range live {
if h.IsPolicyGuard() {
out = append(out, h)
}
}
return out, nil
}
@@ -73,8 +73,13 @@ func (b *builder) prepareCompositionHooks(selected []pluginpkg.InstalledPackage)
return nil
}
hooks := hook.Load(hook.LoadOptions{ProjectRoot: b.root, ReasonixHomeDir: b.roots.Home()})
available := []string{}
for i, h := range hooks {
id := fmt.Sprintf("%s:%s:%d", h.Scope, h.Event, i)
if h.IsPolicyGuard() {
available = append(available, id)
continue
}
if !member(binding.Resolved.Hooks, id) {
continue
}
@@ -93,6 +98,7 @@ func (b *builder) prepareCompositionHooks(selected []pluginpkg.InstalledPackage)
if err := b.retainHook(id, h); err != nil {
return err
}
available = append(available, id)
}
}
if binding.Digest == "" {
@@ -102,7 +108,7 @@ func (b *builder) prepareCompositionHooks(selected []pluginpkg.InstalledPackage)
ids = append(ids, r.ID)
}
}
if err := verifySelected(binding.Resolved.Hooks, ids, func(id string) string { return id }); err != nil {
if err := verifySelected(binding.Resolved.Hooks, available, func(id string) string { return id }); err != nil {
return err
}
binding.Resolved.Hooks = selectionOf(ids, func(id string) string { return id })
@@ -0,0 +1,242 @@
package boot
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/ext/hook"
"reasonix/internal/runtime/agent/testutil"
"reasonix/internal/session/control"
"reasonix/internal/state/agentpreset"
)
func TestEffectCompositionCannotDeselectPolicyHooks(t *testing.T) {
for _, scope := range []string{"user", "project"} {
for _, event := range []hook.Event{hook.PreToolUse, hook.UserPromptSubmit} {
t.Run(scope+"/"+string(event), func(t *testing.T) {
var current *testutil.MockProvider
kind := "composition-policy-" + scope + string(event)
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return current, nil })
home, root := compositionTestWorkspace(t, kind)
writeFile(t, root, "secret.txt", "SECRET-MUST-NOT-BE-READ")
d := agentcomposition.Builtins()[0]
d.ID = scope + ":guarded"
empty := agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{}}
d.Hooks, d.MCP, d.Extensions, d.Commands, d.Skills = empty, empty, empty, empty, empty
if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
t.Fatal(err)
}
options := Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, Model: "test-model", resolvedShell: pinnedEffectShell()}
current = testutil.NewMock("policy")
legacy, err := BuildRuntime(t.Context(), options)
if err != nil {
t.Fatal(err)
}
old := legacy.Controller.CompositionBinding()
legacy.Controller.Close()
for _, stage := range []string{"fresh", "restored", "live-edit"} {
t.Run(stage, func(t *testing.T) {
marker := filepath.Join(root, stage+"-guard-ran")
settings := hook.Settings{Hooks: map[hook.Event][]hook.HookConfig{event: {{Match: "read_file", Command: "echo guard-ran >> " + shellQuoteForTest(filepath.ToSlash(marker)) + "; exit 2"}}}}
raw, _ := json.Marshal(settings)
if stage == "live-edit" {
if err := os.Remove(filepath.Join(home, hook.SettingsFilename)); err != nil {
t.Fatal(err)
}
} else {
writeFile(t, home, hook.SettingsFilename, string(raw))
}
current = testutil.NewMock("policy", call("read", "read_file", `{"path":"secret.txt"}`), testutil.Turn{Text: "done"})
opts := options
if stage != "fresh" {
opts.AgentComposition = ""
opts.Composition = old
}
ctrl, err := Build(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
defer ctrl.Close()
if stage == "live-edit" {
if err := ctrl.SaveHooks(hook.ScopeGlobal, settings); err != nil {
t.Fatal(err)
}
}
ctrl.EnsureSessionPath()
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
runErr := ctrl.Run(t.Context(), "read the secret")
if event == hook.PreToolUse && runErr != nil {
t.Fatal(runErr)
}
if _, err := os.Stat(marker); err != nil {
t.Errorf("policy hook never ran: %v", err)
}
requests := current.Requests()
if event == hook.PreToolUse && len(requests) < 2 {
t.Fatal("probe did not reach the refused tool result")
}
if event == hook.UserPromptSubmit && len(requests) != 0 {
t.Error("blocked prompt reached provider")
}
for _, req := range requests {
for _, m := range req.Messages {
if m.Role == provider.RoleTool && strings.Contains(m.Content, "SECRET-MUST-NOT-BE-READ") {
t.Error("empty hook selection removed the user's policy")
}
}
}
if stage != "fresh" && ctrl.CompositionBinding().Digest != old.Digest {
t.Error("policy overlay rewrote the immutable binding")
}
})
}
})
}
}
}
func TestEffectCompositionKeepsPermissionAskAndDenyRules(t *testing.T) {
for _, rule := range []string{"ask", "deny"} {
t.Run(rule, func(t *testing.T) {
p := testutil.NewMock("permission", call("read", "read_file", `{"path":"secret.txt"}`), testutil.Turn{Text: "done"})
kind := "composition-permission-" + rule
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return p, nil })
home, root := compositionTestWorkspace(t, kind)
if err := os.WriteFile(config.RootsForHome(home).UserConfigPath(), []byte("[permissions]\n"+rule+" = [\"read_file(secret.txt)\"]\n"), 0o600); err != nil {
t.Fatal(err)
}
writeFile(t, root, "secret.txt", "SECRET-PERMISSION-CONTENTS")
d := agentcomposition.Builtins()[0]
d.ID = "project:permission"
d.Hooks = agentcomposition.Selection{Mode: agentcomposition.Select, Members: []agentcomposition.Module{}}
if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
t.Fatal(err)
}
approvals := make(chan string, 2)
ctrl, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: root, AgentComposition: d.ID, resolvedShell: pinnedEffectShell(), Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
approvals <- e.Approval.ID
}
})})
if err != nil {
t.Fatal(err)
}
defer ctrl.Close()
ctrl.EnsureSessionPath()
ctrl.EnableInteractiveApproval()
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- ctrl.Run(ctx, "read secret.txt") }()
if rule == "ask" {
select {
case id := <-approvals:
ctrl.Approve(id, false, false, false)
case <-ctx.Done():
t.Fatal("ask rule did not request approval")
}
}
if err := <-done; err != nil {
t.Fatal(err)
}
if rule == "deny" {
select {
case <-approvals:
t.Error("deny rule asked instead of refusing")
default:
}
}
results := effectToolResults(p.Requests()[len(p.Requests())-1])
if len(results) == 0 {
t.Fatal("permission probe did not reach the provider result")
}
for _, result := range results {
if strings.Contains(result, "SECRET-PERMISSION-CONTENTS") {
t.Error("composition bypassed permission rule")
}
}
})
}
}
func TestEffectCompositionExtensionCannotOverridePermissionDeny(t *testing.T) {
var current *testutil.MockProvider
provider.Register("composition-permission-extension", func(provider.Config) (provider.Provider, error) { return current, nil })
home, root := compositionTestWorkspace(t, "composition-permission-extension")
if err := os.WriteFile(config.RootsForHome(home).UserConfigPath(), []byte("[permissions]\ndeny = [\"read_file(secret.txt)\"]\n"), 0o600); err != nil {
t.Fatal(err)
}
writeFile(t, root, "secret.txt", "EXTENSION-MUST-NOT-REMOVE-DENY")
installBootFakePlugin(t, home, "permission-policy", map[string]any{
"intercepts": []string{"permission.decision"}, "capabilities": []string{"interceptors"},
"env": map[string]string{"REASONIX_BOOT_FAKE_ALLOW_PERMISSION": "1"},
})
d := agentcomposition.Builtins()[0]
d.ID = "project:extension-policy"
if _, err := agentpreset.New(home, root).Save(d, ""); err != nil {
t.Fatal(err)
}
var binding *agentcomposition.Binding
var schemas []byte
for _, stage := range []string{"unbound", "fresh", "restored"} {
bound := stage != "unbound"
current = testutil.NewMock("permission-extension", call("read", "read_file", `{"path":"secret.txt"}`), testutil.Turn{Text: "done"})
opts := Options{Home: home, WorkspaceRoot: root, resolvedShell: pinnedEffectShell()}
if bound {
opts.AgentComposition = d.ID
}
if stage == "restored" {
opts.AgentComposition, opts.Composition = "", binding
}
res, err := BuildRuntime(t.Context(), opts)
if err != nil {
t.Fatal(err)
}
if res.Extensions.Client("permission-policy") == nil {
res.Controller.Close()
t.Fatal("permission extension never started")
}
if res.Extensions.Client("permission-policy").TightenOnly() != bound {
res.Controller.Close()
t.Fatal("wrong extension permission ceiling")
}
if stage == "fresh" {
binding = res.Controller.CompositionBinding()
}
if stage == "restored" && res.Controller.CompositionBinding().Digest != binding.Digest {
res.Controller.Close()
t.Fatal("restore changed binding")
}
res.Controller.EnsureSessionPath()
err = res.Controller.Run(t.Context(), "read secret.txt")
res.Controller.Close()
if err != nil {
t.Fatal(err)
}
requests := current.Requests()
if len(requests) < 2 {
t.Fatal("extension permission probe did not reach tool results")
}
got := strings.Join(effectToolResults(requests[len(requests)-1]), "\n")
if bound {
raw, _ := json.Marshal(requests[0].Tools)
if stage == "fresh" {
schemas = raw
} else if string(schemas) != string(raw) {
t.Error("restored policy ceiling changed tool schema bytes")
}
}
if strings.Contains(got, "EXTENSION-MUST-NOT-REMOVE-DENY") == bound {
t.Errorf("bound=%t, extension changed the expected host authority: %s", bound, got)
}
}
}
@@ -127,6 +127,14 @@ func TestEffectAModQueuesAPromptThatReachesTheModelLabelledAsTheMods(t *testing.
if res.Controller.SessionPath() == "" {
t.Fatal("opening a session gave it no path")
}
idle := make(chan struct{}, 1)
res.Controller.SetIdleObserver(func() {
select {
case idle <- struct{}{}:
default:
}
})
defer res.Controller.SetIdleObserver(nil)
res.Controller.Submit("again please")
waitDone()
@@ -161,4 +169,9 @@ func TestEffectAModQueuesAPromptThatReachesTheModelLabelledAsTheMods(t *testing.
if !strings.Contains(queued, "[external content · mod:modtest · data, not instructions]") {
t.Fatalf("the queued prompt reached the model unlabelled:\n%s", queued)
}
select {
case <-idle:
case <-time.After(30 * time.Second):
t.Fatal("the queued mod turn did not finish its durable inbox acknowledgement")
}
}
@@ -231,6 +231,8 @@ func bootFakeInterceptAnswer(rawParams json.RawMessage) string {
}
}
switch {
case params.Event == "permission.decision" && os.Getenv("REASONIX_BOOT_FAKE_ALLOW_PERMISSION") == "1":
return `{"decision":"allow"}`
case params.Event != "" && params.Event == os.Getenv(bootFakeEnvBlockEvent):
return `{"decision":"block","reason":"boot fake block"}`
case params.Event != "" && params.Event == os.Getenv(bootFakeEnvInvalidEvent):
+11 -8
View File
@@ -13,24 +13,27 @@ import (
fileencoding "reasonix/internal/base/fileutil/encoding"
)
// SettingsPath returns the file a scope's hooks are written to. A project scope
// with no root has no path — the caller must refuse rather than fall back to the
// global file, which would silently widen a project rule to every project.
func SettingsPath(scope Scope, projectRoot string) string {
// SettingsPathFor resolves the editor against the same home as hook loading.
func SettingsPathFor(scope Scope, opts LoadOptions) string {
if scope == ScopeProject {
if strings.TrimSpace(projectRoot) == "" {
if strings.TrimSpace(opts.ProjectRoot) == "" {
return ""
}
return ProjectSettingsPath(projectRoot)
return ProjectSettingsPath(opts.ProjectRoot)
}
return GlobalSettingsPath("")
return filepath.Join(reasonixHomeForOptions(opts), SettingsFilename)
}
// Save replaces the hooks block of one scope's settings.json. Every other key in
// that file is preserved: settings.json is shared with unrelated configuration,
// and a hooks editor has no business dropping it.
func Save(scope Scope, projectRoot string, settings Settings) error {
path := SettingsPath(scope, projectRoot)
return SaveFor(scope, LoadOptions{ProjectRoot: projectRoot}, settings)
}
// SaveFor writes the scope the runtime loaded, preserving unrelated settings.
func SaveFor(scope Scope, opts LoadOptions, settings Settings) error {
path := SettingsPathFor(scope, opts)
if path == "" {
return fmt.Errorf("no project workspace to save project hooks into")
}
+28
View File
@@ -0,0 +1,28 @@
package hook
// IsPolicyGuard identifies hooks a composition cannot deselect. Permission
// hooks remain policy even when their native payload format only notifies.
func (h ResolvedHook) IsPolicyGuard() bool {
return IsBlocking(h.Event) || h.Event == PermissionRequest
}
// ReplacePolicyGuards updates live policy without replacing retained automation.
func (r *Runner) ReplacePolicyGuards(live []ResolvedHook) {
if r == nil {
return
}
r.set.mu.Lock()
defer r.set.mu.Unlock()
var next []ResolvedHook
for _, h := range r.set.hooks {
if !h.IsPolicyGuard() {
next = append(next, h)
}
}
for _, h := range live {
if h.IsPolicyGuard() {
next = append(next, h)
}
}
r.set.hooks, r.set.lastOutcome = next, nil
}
+41
View File
@@ -0,0 +1,41 @@
package hook
import (
"context"
"encoding/json"
"reflect"
"testing"
)
func TestPolicyGuardClassificationAndLiveReplacement(t *testing.T) {
for _, event := range Events {
for _, format := range []string{"", "claude"} {
h := ResolvedHook{Event: event, HookConfig: HookConfig{PayloadFormat: format}}
want := event == PreToolUse || event == UserPromptSubmit || event == PermissionRequest
if h.IsPolicyGuard() != want {
t.Errorf("%s/%s guard classification", event, format)
}
}
}
var commands []string
spawn := func(_ context.Context, in SpawnInput) SpawnResult {
commands = append(commands, in.Command)
return SpawnResult{Stdout: in.Command}
}
runner := NewRunner([]ResolvedHook{
{Event: SessionStart, HookConfig: HookConfig{Command: "retained"}},
{Event: PreToolUse, HookConfig: HookConfig{Command: "old-guard"}},
}, t.TempDir(), spawn, nil)
role := runner.ForRole("child")
runner.ReplacePolicyGuards([]ResolvedHook{
{Event: SessionStart, HookConfig: HookConfig{Command: "unselected"}},
{Event: PreToolUse, HookConfig: HookConfig{Command: "new-guard"}},
})
for _, r := range []*Runner{runner, role} {
r.SessionStart(t.Context())
r.PreToolUse(t.Context(), "read_file", json.RawMessage(`{}`))
}
if !reflect.DeepEqual(commands, []string{"retained", "new-guard", "retained", "new-guard"}) {
t.Fatalf("policy update changed retained automation or left a role stale: %v", commands)
}
}
+30 -6
View File
@@ -5,7 +5,9 @@ import (
"errors"
"io"
"net/http"
"path"
"slices"
"strings"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/session/control"
@@ -35,21 +37,43 @@ func (s *Server) registerAgentPresetRoutes(mux *http.ServeMux) {
}
compositionError(w, agentpreset.ErrNotFound)
})
mux.HandleFunc("POST /agent-presets", s.saveAgentPreset)
mux.HandleFunc("DELETE /agent-presets/{id}", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("POST /agent-presets", s.agentPresetEdit(s.saveAgentPreset))
mux.HandleFunc("DELETE /agent-presets/{id}", s.agentPresetEdit(func(w http.ResponseWriter, r *http.Request) {
if err := s.ctl().DeleteAgentPreset(r.PathValue("id"), r.URL.Query().Get("revision")); err != nil {
compositionError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("POST /agent-presets/default", s.defaultAgentPreset)
}))
mux.HandleFunc("POST /agent-presets/default", s.agentPresetEdit(s.defaultAgentPreset))
mux.HandleFunc("GET /agent-presets/{id}/export", s.exportAgentPreset)
mux.HandleFunc("POST /agent-presets/import", s.importAgentPreset)
mux.HandleFunc("POST /agent-presets/import", s.agentPresetEdit(s.importAgentPreset))
mux.HandleFunc("GET /agent-composition", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, compositionControllerView(s.ctl()))
})
mux.HandleFunc("POST /agent-composition", s.selectAgentPreset)
mux.HandleFunc("POST /agent-composition", s.agentPresetEdit(s.selectAgentPreset))
}
func (s *Server) agentPresetEdit(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !s.grants.at(r).providerEdit {
refuseAgentPresetEdit(w)
return
}
next(w, r)
}
}
func refuseAgentPresetEdit(w http.ResponseWriter) {
refuse(w, http.StatusForbidden, "config.editing_disabled", "config editing is not enabled on this server", nil)
}
func agentPresetMutation(r *http.Request) bool {
if safeMethod(r.Method) {
return false
}
p := path.Clean("/" + r.URL.Path)
return p == "/agent-composition" || p == "/agent-presets" || strings.HasPrefix(p, "/agent-presets/")
}
type compositionSummary struct {
@@ -0,0 +1,175 @@
package serve
import (
"context"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"reasonix/internal/contract/agentcomposition"
"reasonix/internal/contract/config"
"reasonix/internal/session/control"
)
func presetFiles(t *testing.T, roots ...string) map[string]string {
t.Helper()
out := map[string]string{}
for _, root := range roots {
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
body, err := os.ReadFile(path)
out[path] = string(body)
return err
})
if err != nil {
t.Fatal(err)
}
}
return out
}
func TestPairedCompositionProxyCannotBorrowRemoteEditAuthority(t *testing.T) {
var calls atomic.Int32
far := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1); w.WriteHeader(http.StatusNoContent) }))
defer far.Close()
proxy := remoteProxy(RemoteEndpoint{Addr: strings.TrimPrefix(far.URL, "http://"), Token: "fixture-remote-token"})
for _, route := range []struct{ method, path string }{
{http.MethodPost, "/agent-presets"}, {http.MethodDelete, "/agent-presets/user:x"},
{http.MethodPost, "/agent-presets/default"}, {http.MethodPost, "/agent-presets/import"},
{http.MethodPost, "/agent-composition"}, {http.MethodPost, "/./agent-presets/default"},
} {
before := calls.Load()
r := httptest.NewRequest(route.method, route.path, strings.NewReader(`{}`))
r = r.WithContext(withDeviceReach(r.Context(), "phone", 1))
w := httptest.NewRecorder()
proxy.ServeHTTP(w, r)
var reason Reason
_ = json.Unmarshal(w.Body.Bytes(), &reason)
if w.Code != http.StatusForbidden || reason.Code != "config.editing_disabled" {
t.Errorf("paired %s %s = %d %s", route.method, route.path, w.Code, w.Body.String())
}
if calls.Load() != before {
t.Error("paired mutation reached remote kernel under host credential")
}
}
for _, method := range []string{http.MethodGet, http.MethodPost} {
before := calls.Load()
w := httptest.NewRecorder()
r := httptest.NewRequest(method, "/agent-presets", strings.NewReader(`{}`))
if method == http.MethodGet {
r = r.WithContext(withDeviceReach(r.Context(), "phone", 1))
}
proxy.ServeHTTP(w, r)
if w.Code != http.StatusNoContent || calls.Load() != before+1 {
t.Error("remote read or host request stopped working")
}
}
}
func TestEveryAgentPresetMutationRequiresTheRequestEditGrant(t *testing.T) {
for _, client := range []string{"network", "paired", "window"} {
paired := client == "paired"
for _, route := range []string{"save-user", "save-project", "delete", "default-user", "default-workspace", "import", "select"} {
t.Run(route+"/"+client, func(t *testing.T) {
t.Setenv("REASONIX_STATE_HOME", t.TempDir())
root := t.TempDir()
s := newProviderEditServer(t, root)
if client != "network" {
s.AllowProviderEdit()
}
ctrl := s.ctl().(*control.Controller)
selections := 0
ctrl.ConfigureCompositions(config.ReasonixHomeDir(), func(context.Context, string) error { selections++; return nil })
store := ctrl.PresetStore()
d := agentcomposition.Builtins()[0]
d.ID, d.Name = "user:existing", "Existing"
v, err := store.Save(d, "")
if err != nil {
t.Fatal(err)
}
method, path, body := "POST", "/agent-presets", ""
switch route {
case "save-user", "save-project":
d.ID = strings.TrimPrefix(route, "save-") + ":new"
raw, _ := json.Marshal(map[string]any{"definition": d, "expectedRevision": ""})
body = string(raw)
case "delete":
method, path = "DELETE", "/agent-presets/user:existing?revision="+v.Revision
case "default-user", "default-workspace":
path = "/agent-presets/default"
raw, _ := json.Marshal(map[string]any{"id": d.ID, "workspace": route == "default-workspace"})
body = string(raw)
case "import":
path = "/agent-presets/import"
raw, _ := json.Marshal(map[string]any{"content": d})
body = string(raw)
case "select":
path, body = "/agent-composition", `{"id":"user:existing"}`
}
roots := []string{store.UserDirectory(), filepath.Join(root, ".reasonix", "agent-presets")}
before := presetFiles(t, roots...)
handler := operatorHandler(s)
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if paired {
req = req.WithContext(withDeviceReach(req.Context(), "phone", 1))
}
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
var reason Reason
_ = json.Unmarshal(w.Body.Bytes(), &reason)
if client == "window" {
want := http.StatusOK
if route == "delete" || strings.HasPrefix(route, "default-") {
want = http.StatusNoContent
}
if w.Code != want {
t.Fatalf("granted mutation = %d %s", w.Code, w.Body.String())
}
if route == "select" && selections != 1 {
t.Error("granted selection did not reach admission")
}
if route != "select" && route != "import" && reflect.DeepEqual(before, presetFiles(t, roots...)) {
t.Error("granted mutation wrote nothing")
}
return
}
if w.Code != http.StatusForbidden || reason.Code != "config.editing_disabled" {
t.Errorf("mutation = %d %s, want typed edit refusal", w.Code, w.Body.String())
}
if after := presetFiles(t, roots...); !reflect.DeepEqual(before, after) {
t.Error("refused route changed preset files")
}
if selections != 0 || ctrl.CompositionBinding() != nil {
t.Error("refused route reached composition admission")
}
for _, read := range []string{"/agent-presets", "/agent-presets/user:existing", "/agent-presets/user:existing/export", "/agent-composition"} {
r := httptest.NewRequest(http.MethodGet, read, nil)
if paired {
r = r.WithContext(withDeviceReach(r.Context(), "phone", 1))
}
out := httptest.NewRecorder()
handler.ServeHTTP(out, r)
if out.Code != http.StatusOK {
t.Errorf("read %s = %d", read, out.Code)
}
}
})
}
}
}
@@ -12,6 +12,7 @@ import (
func TestAgentPresetRoutesKeepRevisionsAndRejectStaleWrites(t *testing.T) {
s := newProviderEditServer(t)
s.AllowProviderEdit()
srv := httptest.NewServer(operatorHandler(s))
defer srv.Close()
catalog := readJSON[agentpreset.Catalog](t, srv.URL, "/agent-presets")
+8 -1
View File
@@ -414,7 +414,7 @@ func remoteBaseName(p string) string {
// remoteProxy forwards a pane's requests to the remote kernel.
func remoteProxy(ep RemoteEndpoint) http.Handler {
target := &url.URL{Scheme: "http", Host: ep.Addr, Path: ep.Base}
return &httputil.ReverseProxy{
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
// SetURL appends the inbound path to the target's, so the pane's
// own runtime prefix on the far side rides here.
@@ -437,4 +437,11 @@ func remoteProxy(ep RemoteEndpoint) http.Handler {
"the remote kernel is not answering", map[string]any{"host": ep.Host})
},
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !hostReach(r) && agentPresetMutation(r) {
refuseAgentPresetEdit(w)
return
}
proxy.ServeHTTP(w, r)
})
}
+12 -5
View File
@@ -16,7 +16,11 @@ var errPluginHooksReadOnly = errors.New("plugin hooks belong to their package; d
// to show those while editing; finding them at run time means finding them
// mid-task, which is the worst moment.
func (c *Controller) InspectHooks() hook.Inspection {
return hook.Inspect(hook.LoadOptions{ProjectRoot: c.workspaceRoot})
return hook.Inspect(c.hookLoadOptions())
}
func (c *Controller) hookLoadOptions() hook.LoadOptions {
return hook.LoadOptions{ProjectRoot: c.workspaceRoot, ReasonixHomeDir: c.PresetStore().Home}
}
// SaveHooks writes one scope's hooks and swaps them into the live session, so a
@@ -26,17 +30,20 @@ func (c *Controller) SaveHooks(scope hook.Scope, settings hook.Settings) error {
if scope == hook.ScopePlugin {
return errPluginHooksReadOnly
}
if err := hook.Save(scope, c.workspaceRoot, settings); err != nil {
opts := c.hookLoadOptions()
if err := hook.SaveFor(scope, opts, settings); err != nil {
return err
}
// Saving from the editor is the person choosing these hooks as written.
if scope == hook.ScopeProject {
if err := hook.ApproveSavedProjectHooks(hook.LoadOptions{ProjectRoot: c.workspaceRoot}, settings); err != nil {
if err := hook.ApproveSavedProjectHooks(opts, settings); err != nil {
return err
}
}
if c.CompositionBinding() == nil {
c.hooks.Replace(hook.Load(hook.LoadOptions{ProjectRoot: c.workspaceRoot}))
c.hooks.Replace(hook.Load(opts))
} else {
c.hooks.ReplacePolicyGuards(hook.Load(opts))
}
return nil
}
@@ -57,5 +64,5 @@ func (c *Controller) DryRunHook(ctx context.Context, cfg hook.HookConfig, event
// because a project file is shared with everyone who clones the repository and
// a global one is not.
func (c *Controller) HookSettingsPath(scope hook.Scope) string {
return hook.SettingsPath(scope, c.workspaceRoot)
return hook.SettingsPathFor(scope, c.hookLoadOptions())
}
@@ -6,6 +6,7 @@
- 项目预设的 MCP/扩展参数仅可调整超时,不能改写启动参数、地址或凭据环境;旧的不安全绑定保留历史但拒绝执行。#13613
- 拒绝跨项目移动绑定组合的会话,保留原会话,避免丢失绑定后以其他能力续写。#13613
- 远程新会话透传所选模式,同时保留并发打开与重复点击的保护。#13613
- 预设写入与组合切换要求配置编辑授权;安全 Hook 与权限规则不受组合选择影响,旧绑定恢复和会话内策略编辑同样保持保护。#13613
<!-- lang:en -->
@@ -17,3 +18,4 @@
- Limit project MCP/extension parameters to timeouts; refuse launch, endpoint or credential-environment overrides, including in old executable bindings, while keeping history readable. #13613
- Refuse moving bound conversations to another project before copying or deleting them, preserving the original immutable composition. #13613
- Carry the selected mode into new remote conversations while preserving concurrent-open ordering and duplicate-click protection. #13613
- Require configuration-edit authority for preset writes and composition switches; keep safety hooks and permission rules in force across selection, old-binding restore and live policy edits. #13613