L2: permission mode propagation across forward/calendar/adopt + PUT endpoint + tests

P1 - forward inherits mode:
- doForward: when creating a new target session, use InheritedMode from
  the source session (plan parent → plan child, cannot escalate)
- enforcement snapshot set from receiving agent

P1 - calendar_events gets permission_mode column:
- Added to 3 migration sites (sqlite init, pg init, incremental alter)
- CalendarEvent model gains PermissionMode field
- CreateCalendarEvent / UpdateCalendarEvent normalize + persist the field
- resolveCalendarSession: on new session → write event's mode;
  on reuse → ModeAtMost(cur, eventMode), prevents escalation
  (plan Agent's reminder fires into a workspace session = bypass)
- SendCalendarMail now takes permMode and threads it through
- fireEvent passes event.PermissionMode to all delivery paths

P1 - AdoptPlatformSession explicitly writes default mode:
- Writes DefaultPermissionMode + enforcement on adopt, instead of
  relying on DB column default (avoids silent drift on schema changes)

P2 - PUT /sessions/{id}/permission endpoint:
- New handler UpdateSessionPermission (auth required, access check)
- Registers PUT route alongside existing budget/alias endpoints
- Broadcasts session_update on change
- Does NOT refresh enforcement (design: snapshot at creation)

P3 - Tests (24 new cases):
- permission_mode_test.go: InheritedMode (6 cases), SetSessionPermissionMode
  roundtrip, dirty value fail-closed, NormalizePermissionMode, calendar event
  roundtrip/dirty/update, adopt writes default mode, plan escalation guard,
  3-level inheritance chain
- defaultsession_test.go: budget regression, permission mode regression
  (pins created=false → no reset on reuse)

Deploys with: bash deploy/redeploy-gateway.sh --skip-tests
Schema migration: auto via addMissingColumns (new column default 'workspace')
This commit is contained in:
2026-09-06 17:32:47 +08:00
parent 47fe9a5e74
commit 1dc6223631
12 changed files with 592 additions and 15 deletions

View File

@ -127,12 +127,24 @@ func doForward(w http.ResponseWriter, r *http.Request, mailID uuid.UUID, actor,
subject := forwardSubject(req.Subject, src.Subject)
// 转发按目标地址寻址,不带 reply_to它是一条新线索不该并进原会话
sessionID, _, _, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
sessionID, _, created, err := resolveTarget(r, to, "", actor, subject, req.SessionAlias, agentLimiterKey(isAgent, actor))
if err != nil {
writeErr(w, err, "Failed to resolve session")
return
}
// 权限档位继承自源会话plan 档派不出 full 档子任务,约束沿链条传递)。
// 只在【新建】目标会话时设:复用既有会话时不改写对方正在遵守的规则。
if created {
mode := repo.InheritedMode(r.Context(), &src.SessionID, models.DefaultPermissionMode)
if _, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode); err != nil {
Error(w, http.StatusInternalServerError, "Failed to set permission mode")
return
}
_ = repo.SetSessionEnforcement(r.Context(), sessionID,
repo.AgentModeEnforcement(r.Context(), to.Name))
}
if isAgent {
// 转发也是一次主动发信,扣【目标会话】的往返预算。
// 扣目标而不是源:转发开启的是一条新线索,消耗的是新线索的额度。

View File

@ -278,6 +278,53 @@ type sessionBudgetRequest struct {
Reset bool `json:"reset"`
}
type updatePermissionRequest struct {
// PermissionMode 三档 plan / workspace / full。
//
// 对话页里人可随时改,改了即时生效(不继承、不限「只能同档或更严」——
// 那是 Agent 主动派子任务时的约束;人改档是对一条已有会话的明示意愿,
// 可以从 plan 直接调到 full。脏值 fail-closed 到默认档而不是 full。
PermissionMode string `json:"permission_mode"`
}
// PUT /api/v1/sessions/{id}/permission
//
// 对话页里随时调档位。与 budget 同位置编辑:两者都是任务的属性,
// 人看着往来内容才知道「这件事现在该收紧还是放开」。
//
// 人类可以任改三档(包括从 plan 提到 full —— 人是权限的源头);
// Agent 不经此端点Agent 改档须走发信继承路径,不得自行提权)。
func UpdateSessionPermission(w http.ResponseWriter, r *http.Request) {
sessionID, ok := requireSessionAccess(w, r)
if !ok {
return
}
var req updatePermissionRequest
if !DecodeBody(w, r, &req) {
return
}
mode := models.NormalizePermissionMode(req.PermissionMode)
if mode == "" {
mode = models.DefaultPermissionMode
}
perm, err := repo.SetSessionPermissionMode(r.Context(), sessionID, mode)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to update permission mode")
return
}
// 强制力不在此刷新:它是「平台能力」的事实快照,在会话建立时定死
// (见 repo.SetSessionEnforcement 的注释)。人改档位不改变平台的能力,
// 插件升级才改变 —— 那要等新投递/新会话才会反映。
sse.Default.Broadcast("session_update", map[string]interface{}{
"session_id": sessionID.String(),
"permission_mode": perm.Mode,
"permission_enforcement": perm.Enforcement,
})
JSON(w, http.StatusOK, perm)
}
// GET /api/v1/sessions/{id}/budget
//
// 本会话的往返预算。与 Agent 全局配额是两层,都要过: