mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ©
|
||||
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):
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user