diff --git a/gateway/cmd/server/main.go b/gateway/cmd/server/main.go index 9100e6b..96e4696 100644 --- a/gateway/cmd/server/main.go +++ b/gateway/cmd/server/main.go @@ -182,6 +182,8 @@ func main() { // 本任务的往返预算:在对话页里随时可改 r.Get("/sessions/{id}/budget", handler.GetSessionBudgetHandler) r.Put("/sessions/{id}/budget", handler.UpdateSessionBudget) + // 本任务的权限档位:在对话页里随时可改 + r.Put("/sessions/{id}/permission", handler.UpdateSessionPermission) // Agent 在正文里提的改名建议:读取与驳回(接受走上面的 PUT alias) r.Get("/sessions/{id}/rename-proposal", handler.GetRenameProposal) r.Post("/sessions/{id}/rename-proposal/dismiss", handler.DismissRenameProposal) diff --git a/gateway/internal/db/migrate.go b/gateway/internal/db/migrate.go index 0078b61..cd585a5 100644 --- a/gateway/internal/db/migrate.go +++ b/gateway/internal/db/migrate.go @@ -84,10 +84,17 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{ // to_address / agent_name,历史事件因此继续工作,不需要数据迁移。 {"calendar_events", "recipients", "ALTER TABLE calendar_events ADD COLUMN recipients TEXT NOT NULL DEFAULT '[]'"}, {"calendar_events", "delivery_mode", "ALTER TABLE calendar_events ADD COLUMN delivery_mode TEXT NOT NULL DEFAULT 'separate'"}, - // 日历事件已触发的 occurrence。旧库为 NULL:等价于「从未触发」, + // 日历事件已触发的 occurrence。旧库为 NULL:等价于「从未触发」, // 于是已过期的一次性事件会补发一次提醒 —— 这是可接受的, // 而反过来(默认成 event_time)会让正在等的提醒永远发不出去。 {"calendar_events", "fired_for", "ALTER TABLE calendar_events ADD COLUMN fired_for DATETIME"}, + // 日历事件的权限档位(plan / workspace / full)。事件触发时若新建会话, + // 用这一列定死档位;复用已有会话则取「会话现档 与 事件档」中更严那个。 + // + // 旧库默认 'workspace':历史事件补发提醒不该静默升到 full(提权路径 + // 会被 P1 calendar 投递接线堵住,但这里默认值也得守住)。 + // 与 sessions.permission_mode 的默认取向一致。 + {"calendar_events", "permission_mode", "ALTER TABLE calendar_events ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'workspace'"}, // 本侧会话接管的平台会话 id。旧库默认空串 = 「不是接管来的」, // 与新建会话的语义一致,不需要数据迁移。 {"sessions", "platform_id", "ALTER TABLE sessions ADD COLUMN platform_id TEXT NOT NULL DEFAULT ''"}, diff --git a/gateway/internal/db/migrations/init.sql b/gateway/internal/db/migrations/init.sql index e94e1ea..36c2e0b 100644 --- a/gateway/internal/db/migrations/init.sql +++ b/gateway/internal/db/migrations/init.sql @@ -395,6 +395,8 @@ CREATE TABLE IF NOT EXISTS calendar_events ( last_fired_at TIMESTAMPTZ, -- 已触发的 occurrence(= 当时的 event_time)。见 init_sqlite.sql 的说明。 fired_for TIMESTAMPTZ, + -- 权限档位(plan / workspace / full)。见 init_sqlite.sql 的说明。 + permission_mode TEXT NOT NULL DEFAULT 'workspace', created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), created_by VARCHAR(128) NOT NULL DEFAULT '' diff --git a/gateway/internal/db/migrations/init_sqlite.sql b/gateway/internal/db/migrations/init_sqlite.sql index 6903229..9a4f673 100644 --- a/gateway/internal/db/migrations/init_sqlite.sql +++ b/gateway/internal/db/migrations/init_sqlite.sql @@ -433,6 +433,10 @@ CREATE TABLE IF NOT EXISTS calendar_events ( -- 按 occurrence 比相等则精确:AdvanceRecurrence 改了 event_time 就再触发, -- 没改就永不重发。 fired_for DATETIME, + -- 权限档位(plan / workspace / full)。事件触发时新建会话 → 用此档位定死; + -- 复用已有会话 → 取「会话现档 与 事件档」中更严那个(ModeAtMost), + -- 不允许通过重复事件提权(plan 档 Agent 建的日程触发时拿 workspace 就绕开了 plan)。 + permission_mode TEXT NOT NULL DEFAULT 'workspace', created_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), updated_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')), diff --git a/gateway/internal/handler/forward.go b/gateway/internal/handler/forward.go index 5171134..1789ab2 100644 --- a/gateway/internal/handler/forward.go +++ b/gateway/internal/handler/forward.go @@ -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 { // 转发也是一次主动发信,扣【目标会话】的往返预算。 // 扣目标而不是源:转发开启的是一条新线索,消耗的是新线索的额度。 diff --git a/gateway/internal/handler/sessions.go b/gateway/internal/handler/sessions.go index b055573..19cdb52 100644 --- a/gateway/internal/handler/sessions.go +++ b/gateway/internal/handler/sessions.go @@ -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 全局配额是两层,都要过: diff --git a/gateway/internal/models/calendar.go b/gateway/internal/models/calendar.go index 22133ff..dd25d6b 100644 --- a/gateway/internal/models/calendar.go +++ b/gateway/internal/models/calendar.go @@ -53,6 +53,13 @@ type CalendarEvent struct { Status string `json:"status"` // active/paused/cancelled LastFiredAt *time.Time `json:"last_fired_at,omitempty"` + // PermissionMode 是事件触发时新建会话应采用的档位(plan / workspace / full)。 + // 空 = workspace(默认)。 + // 复用已有会话时不能直接搬用:要 ModeAtMost(会话现档, 事件档) —— + // 事件档表示「这件事允许到什么程度」,而会话现档可能更严(plan 档派出的 + // 任务不该因为日程触发就偷偷升到 workspace)。 + PermissionMode string `json:"permission_mode"` + // FiredFor 是已触发的那个 occurrence(值 = 当时的 EventTime)。 // 去重靠它与 EventTime 相等判断,不是拿 LastFiredAt 比大小 —— // DueEvents 有 60 秒 lookahead,后者在窗口内恒为真会导致每 tick 重发。 diff --git a/gateway/internal/repo/calendar.go b/gateway/internal/repo/calendar.go index b7f6d95..c41055f 100644 --- a/gateway/internal/repo/calendar.go +++ b/gateway/internal/repo/calendar.go @@ -25,7 +25,7 @@ var ErrEventNotFound = errors.New("calendar event not found") // 加了预算两列没加进 Scan,整个联系人栏 500)。 const calendarCols = `event_id, title, description, reminder_text, agent_name, to_address, recipients, delivery_mode, event_time, remind_before, recurrence, recurrence_end, - status, last_fired_at, fired_for, created_at, updated_at, created_by` + status, last_fired_at, fired_for, permission_mode, created_at, updated_at, created_by` // rowScanner 让 QueryRow 与 Rows 共用同一个 scan 实现。 type rowScanner interface { @@ -44,7 +44,8 @@ func scanCalendarEvent(sc rowScanner) (*models.CalendarEvent, error) { &e.AgentName, &e.ToAddress, &recipientsJSON, &e.DeliveryMode, &e.EventTime, &e.RemindBefore, &e.Recurrence, &e.RecurrenceEnd, - &e.Status, &e.LastFiredAt, &e.FiredFor, &e.CreatedAt, &e.UpdatedAt, &e.CreatedBy, + &e.Status, &e.LastFiredAt, &e.FiredFor, &e.PermissionMode, + &e.CreatedAt, &e.UpdatedAt, &e.CreatedBy, ); err != nil { return nil, err } @@ -88,6 +89,10 @@ func CreateCalendarEvent(ctx context.Context, e *models.CalendarEvent) (*models. if e.DeliveryMode == "" { e.DeliveryMode = models.DeliverSeparate } + // 档位合法化:脏值 fail-closed 到默认档,不透传成库里的非法值 + //(否则后续读路径会拿到一个 ModeNeedsHuman 判定不了的值)。 + e.PermissionMode = models.NormalizePermissionMode(e.PermissionMode) + if e.Recipients == nil { e.Recipients = []string{} } @@ -96,14 +101,14 @@ func CreateCalendarEvent(ctx context.Context, e *models.CalendarEvent) (*models. INSERT INTO calendar_events (event_id, title, description, reminder_text, agent_name, to_address, recipients, delivery_mode, - event_time, remind_before, recurrence, recurrence_end, status, created_by, + event_time, remind_before, recurrence, recurrence_end, status, permission_mode, created_by, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, e.EventID, e.Title, e.Description, e.ReminderText, e.AgentName, e.ToAddress, marshalRecipients(e.Recipients), e.DeliveryMode, e.EventTime, e.RemindBefore, e.Recurrence, e.RecurrenceEnd, - e.Status, e.CreatedBy, e.CreatedAt, e.UpdatedAt, + e.Status, e.PermissionMode, e.CreatedBy, e.CreatedAt, e.UpdatedAt, ) if err != nil { return nil, err @@ -125,19 +130,21 @@ func GetCalendarEvent(ctx context.Context, eventID string) (*models.CalendarEven func UpdateCalendarEvent(ctx context.Context, eventID string, e *models.CalendarEvent) error { e.UpdatedAt = time.Now() + // 档位同样在 update 路径上规范化 + e.PermissionMode = models.NormalizePermissionMode(e.PermissionMode) result, err := db.DB.ExecContext(ctx, ` UPDATE calendar_events SET title = ?, description = ?, reminder_text = ?, agent_name = ?, to_address = ?, recipients = ?, delivery_mode = ?, event_time = ?, remind_before = ?, recurrence = ?, recurrence_end = ?, - status = ?, updated_at = ? + status = ?, permission_mode = ?, updated_at = ? WHERE event_id = ?`, e.Title, e.Description, e.ReminderText, e.AgentName, e.ToAddress, marshalRecipients(e.Recipients), e.EffectiveDeliveryMode(), e.EventTime, e.RemindBefore, e.Recurrence, e.RecurrenceEnd, - e.Status, e.UpdatedAt, eventID, + e.Status, e.PermissionMode, e.UpdatedAt, eventID, ) if err != nil { return err diff --git a/gateway/internal/repo/defaultsession_test.go b/gateway/internal/repo/defaultsession_test.go index 48d7bd7..789fb09 100644 --- a/gateway/internal/repo/defaultsession_test.go +++ b/gateway/internal/repo/defaultsession_test.go @@ -179,3 +179,91 @@ func TestLegacyWrapperStillWorks(t *testing.T) { t.Fatalf("包装函数应与原行为一致:%s vs %s", id, again) } } + +// ─── 预算不被冲的回归用例(P0 第一项,P3 钉死) ─── +// +// 守卫原本是 `parentMailID == nil`(表示新建),但省略 session 位复用默认会话时 +// parentMailID 也是 nil —— 于是「仅在新建时生效」的字段(预算、档位)在每封 +// 省略 session 位的信上都被重写了。实测:第一封 max_rounds=7 → 第二封省略该 +// 字段 → 预算被静默改成默认的 20。 +// +// 修法:handler 改用 FindOrCreateDefaultSessionCreated 返回的 `created` 判据。 +// 本测试钉死「复用默认会话时 created=false」这一事实,让守卫不会倒退回去。 +func TestDefaultSessionReuseBudgetNotReset(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + first, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封") + if err != nil || !created { + t.Fatalf("首封应新建:created=%v err=%v", created, err) + } + // 模拟发信路径:新建会话时定预算为 7(低于默认 20) + if _, err := SetSessionBudget(ctx, first, 7); err != nil { + t.Fatal(err) + } + // 复用的前提是会话里有该收件人参与过的邮件 + if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil { + t.Fatal(err) + } + + // 第二封省略 session 位 → 复用默认会话,created 必须为 false + second, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封") + if err != nil { + t.Fatal(err) + } + if second != first { + t.Fatalf("第二封应复用同一会话:%s vs %s", first, second) + } + if created2 { + t.Fatal("复用默认会话时 created 必须为 false,否则预算会被默认值冲掉") + } + + // 既然 created2=false,发信路径不会调 SetSessionBudget → 预算仍为 7 + b, err := GetSessionBudget(ctx, first) + if err != nil { + t.Fatal(err) + } + if b.Max != 7 { + t.Errorf("预算被冲掉:got max=%d,want 7(复用不应重设)", b.Max) + } +} + +// ─── 档位同样不被冲的回归用例 ─── +// +// 与预算同理:复用默认会话时档位也不该被重设成默认档。 +// 人指定 plan 档后,第二封信省略 session 位复用同一条会话 → +// created=false → SetSessionPermissionMode 不被调 → 档位仍为 plan。 +func TestDefaultSessionReusePermissionModeNotReset(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + first, created, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "首封") + if err != nil || !created { + t.Fatalf("首封应新建:created=%v err=%v", created, err) + } + // 新建会话时定档位为 plan(比默认 workspace 更严) + if _, err := SetSessionPermissionMode(ctx, first, "plan"); err != nil { + t.Fatal(err) + } + if _, err := CreateMail(ctx, first, nil, "jianf", "", "pi", "/w", "首封", "x", nil); err != nil { + t.Fatal(err) + } + + second, created2, err := FindOrCreateDefaultSessionCreated(ctx, "pi", "/w", "jianf", "第二封") + if err != nil { + t.Fatal(err) + } + if second != first { + t.Fatalf("第二封应复用同一会话:%s vs %s", first, second) + } + if created2 { + t.Fatal("复用默认会话时 created 必须为 false,否则档位会被默认值冲掉") + } + + mode := SessionPermissionMode(ctx, first) + if mode != "plan" { + t.Errorf("档位被冲掉:got %q,want plan(复用不应重设)", mode) + } +} diff --git a/gateway/internal/repo/permission_mode_test.go b/gateway/internal/repo/permission_mode_test.go new file mode 100644 index 0000000..9d9adab --- /dev/null +++ b/gateway/internal/repo/permission_mode_test.go @@ -0,0 +1,369 @@ +package repo + +import ( + "context" + "testing" + + "github.com/agentmail/gateway/internal/models" + "github.com/google/uuid" +) + +// ─── InheritedMode:继承、收紧、不存在的父会话 ─── + +// parent nil → 返回 requested 的规范化值 +func TestInheritedMode_NilParent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + got := InheritedMode(ctx, nil, "plan") + if got != models.ModePlan { + t.Errorf("nil parent + plan: got %q, want plan", got) + } + + got = InheritedMode(ctx, nil, "workspace") + if got != models.ModeWorkspace { + t.Errorf("nil parent + workspace: got %q, want workspace", got) + } + + got = InheritedMode(ctx, nil, "full") + if got != models.ModeFull { + t.Errorf("nil parent + full: got %q, want full", got) + } +} + +// parent plan → 子会话只能 plan(不能提权) +func TestInheritedMode_ParentPlan_Tightens(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + id := createTestSession(t, ctx, "pi", "/ws") + perm, err := SetSessionPermissionMode(ctx, id, models.ModePlan) + if err != nil { + t.Fatal(err) + } + if perm.Mode != models.ModePlan { + t.Fatal("expected plan") + } + + // 请求 workspace(更宽松)→ 应被收紧为 plan + got := InheritedMode(ctx, &id, models.ModeWorkspace) + if got != models.ModePlan { + t.Errorf("plan parent + workspace request: got %q, want plan", got) + } + + // 请求 full → 同样收紧 + got = InheritedMode(ctx, &id, models.ModeFull) + if got != models.ModePlan { + t.Errorf("plan parent + full request: got %q, want plan", got) + } + + // 请求 plan → 保持 plan + got = InheritedMode(ctx, &id, models.ModePlan) + if got != models.ModePlan { + t.Errorf("plan parent + plan request: got %q, want plan", got) + } +} + +// parent workspace → 子会话 workspace 或更严 +func TestInheritedMode_ParentWorkspace(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + id := createTestSession(t, ctx, "pi", "/ws") + _, err := SetSessionPermissionMode(ctx, id, models.ModeWorkspace) + if err != nil { + t.Fatal(err) + } + + // 请求 full → 收紧为 workspace(子不能比父更松) + got := InheritedMode(ctx, &id, models.ModeFull) + if got != models.ModeWorkspace { + t.Errorf("workspace parent + full: got %q, want workspace", got) + } + + // 请求 plan → 保留 plan(比父更严,允许) + got = InheritedMode(ctx, &id, models.ModePlan) + if got != models.ModePlan { + t.Errorf("workspace parent + plan: got %q, want plan", got) + } +} + +// parent full → 子会话可请求任意档位 +func TestInheritedMode_ParentFull(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + id := createTestSession(t, ctx, "pi", "/ws") + _, err := SetSessionPermissionMode(ctx, id, models.ModeFull) + if err != nil { + t.Fatal(err) + } + + got := InheritedMode(ctx, &id, models.ModeWorkspace) + if got != models.ModeWorkspace { + t.Errorf("full parent + workspace: got %q, want workspace", got) + } + got = InheritedMode(ctx, &id, models.ModePlan) + if got != models.ModePlan { + t.Errorf("full parent + plan: got %q, want plan", got) + } +} + +// 脏值 fallback:非法的 requested 值在 InheritedMode 里被规范化为默认档 +func TestInheritedMode_InvalidRequested(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + got := InheritedMode(ctx, nil, "elephant") + if got != models.DefaultPermissionMode { + t.Errorf("invalid requested: got %q, want %q", got, models.DefaultPermissionMode) + } +} + +// parent 不存在时(查不到)回落:ModeAtMost(default, req) +func TestInheritedMode_InvalidParent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + fakeID := uuid.New() + got := InheritedMode(ctx, &fakeID, "full") + want := models.ModeAtMost(models.DefaultPermissionMode, "full") + if got != want { + t.Errorf("invalid parent + full: got %q, want %q (modeAtMost(default, full))", got, want) + } +} + +// ─── SetSessionPermissionMode roundtrip + dirty value normalization ─── + +func TestSetSessionPermissionMode_Roundtrip(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "dsh", 20) + + id := createTestSession(t, ctx, "dsh", "/ws") + perm, err := SetSessionPermissionMode(ctx, id, models.ModeFull) + if err != nil { + t.Fatal(err) + } + if perm.Mode != models.ModeFull || perm.Enforcement != "advisory" { + t.Errorf("full mode: got mode=%q enforcement=%q", perm.Mode, perm.Enforcement) + } + + // 读出来一致 + got := SessionPermissionMode(ctx, id) + if got != models.ModeFull { + t.Errorf("read back: got %q, want full", got) + } +} + +func TestSetSessionPermissionMode_DirtyValue_FailClosed(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + id := createTestSession(t, ctx, "pi", "/ws") + perm, err := SetSessionPermissionMode(ctx, id, "BOGUS") + if err != nil { + t.Fatal(err) + } + if perm.Mode != models.DefaultPermissionMode { + t.Errorf("dirty value: got %q, want %q (fail-closed to default)", perm.Mode, models.DefaultPermissionMode) + } +} + +// ─── 脏值归一化测试(覆盖 NormalizePermissionMode 本身) ─── + +func TestNormalizePermissionMode_Inputs(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"plan", "plan"}, + {"workspace", "workspace"}, + {"full", "full"}, + // 大小写/空白不归一:NormalizePermissionMode 只接受精确匹配的合法档位, + // 其余一律 fail-closed 到默认档(workspace)—— 不 trim 不 lowercase, + // 避免「我以为给了 plan 实际拿到别的」这种隐式转换造成的安全错觉。 + {"Plan", models.DefaultPermissionMode}, + {" PLAN ", models.DefaultPermissionMode}, + {"", models.DefaultPermissionMode}, + {"bogus", models.DefaultPermissionMode}, + {"F ULL", models.DefaultPermissionMode}, + } + for _, tt := range tests { + got := models.NormalizePermissionMode(tt.input) + if got != tt.want { + t.Errorf("NormalizePermissionMode(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +// ─── 负向对照:plan 档不能提权 ─── + +func TestInheritedMode_PlanCannotEscalate(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + parent := createTestSession(t, ctx, "pi", "/ws") + _, _ = SetSessionPermissionMode(ctx, parent, models.ModePlan) + + child := InheritedMode(ctx, &parent, models.ModeFull) + if child != models.ModePlan { + t.Errorf("SECURITY FAIL: plan session escalated to %q via InheritedMode", child) + } +} + +// ─── 三层继承链 ─── + +func TestInheritedMode_ThreeLevelChain(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + root := createTestSession(t, ctx, "pi", "/ws") + _, _ = SetSessionPermissionMode(ctx, root, models.ModeFull) + + child := InheritedMode(ctx, &root, models.ModeWorkspace) // workspace < full → workspace + childID := createTestSession(t, ctx, "pi", "/ws") + _, _ = SetSessionPermissionMode(ctx, childID, child) + + grandchild := InheritedMode(ctx, &childID, models.ModeFull) // full vs workspace → workspace + if grandchild != models.ModeWorkspace { + t.Errorf("grandchild: got %q, want workspace", grandchild) + } + + // plan → workspace → plan chain + planChild := InheritedMode(ctx, &root, models.ModePlan) // plan < full → plan + planChildID := createTestSession(t, ctx, "pi", "/ws") + _, _ = SetSessionPermissionMode(ctx, planChildID, planChild) + + grandchild2 := InheritedMode(ctx, &planChildID, models.ModeFull) // full vs plan → plan + if grandchild2 != models.ModePlan { + t.Errorf("plan chain grandchild: got %q, want plan", grandchild2) + } +} + +// ─── calendar_events permission_mode roundtrip ─── + +func TestCalendarEventPermissionMode_Roundtrip(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + // CreateCalendarEvent 应规范化档位 + e := &models.CalendarEvent{ + Title: "测试日程", + AgentName: "pi", + ToAddress: "pi@/home/program/agentmail", + PermissionMode: "full", + Status: "active", + CreatedBy: "jianf", + } + created, err := CreateCalendarEvent(ctx, e) + if err != nil { + t.Fatal(err) + } + if created.PermissionMode != "full" { + t.Errorf("created event permission_mode: got %q, want full", created.PermissionMode) + } + + // 读回来一致 + got, err := GetCalendarEvent(ctx, created.EventID) + if err != nil { + t.Fatal(err) + } + if got.PermissionMode != "full" { + t.Errorf("read back: got %q, want full", got.PermissionMode) + } +} + +func TestCalendarEventPermissionMode_DirtyValue_Normalized(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + e := &models.CalendarEvent{ + Title: "脏值日程", + AgentName: "pi", + ToAddress: "pi@/home/program/agentmail", + PermissionMode: "INVALID", + Status: "active", + CreatedBy: "jianf", + } + created, err := CreateCalendarEvent(ctx, e) + if err != nil { + t.Fatal(err) + } + if created.PermissionMode != models.DefaultPermissionMode { + t.Errorf("dirty value: got %q, want %q", created.PermissionMode, models.DefaultPermissionMode) + } +} + +func TestCalendarEventPermissionMode_UpdateRoundtrip(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + e := &models.CalendarEvent{ + Title: "更新日程", + AgentName: "pi", + ToAddress: "pi@/home/program/agentmail", + PermissionMode: "workspace", + Status: "active", + CreatedBy: "jianf", + } + created, err := CreateCalendarEvent(ctx, e) + if err != nil { + t.Fatal(err) + } + + created.PermissionMode = "plan" + if err := UpdateCalendarEvent(ctx, created.EventID, created); err != nil { + t.Fatal(err) + } + got, err := GetCalendarEvent(ctx, created.EventID) + if err != nil { + t.Fatal(err) + } + if got.PermissionMode != "plan" { + t.Errorf("after update: got %q, want plan", got.PermissionMode) + } +} + +// ─── adopt 接管时会话档位必须写入 ─── + +func TestAdoptPlatformSession_WritesDefaultMode(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + seedAgent(t, "pi", 20) + + id, err := AdoptPlatformSession(ctx, "pi", "plat-123", "my-proj", "/ws", "接管测试") + if err != nil { + t.Fatal(err) + } + // 接管会话应显式写入默认档位(不是靠 DB 默认值) + mode := SessionPermissionMode(ctx, id) + if mode != models.DefaultPermissionMode { + t.Errorf("adopt session mode: got %q, want %q", mode, models.DefaultPermissionMode) + } +} + +// ─── helpers ─── + +func createTestSession(t *testing.T, ctx context.Context, agent, workspace string) uuid.UUID { + t.Helper() + id, err := CreateSession(ctx, nil, agent, "test subject", workspace) + if err != nil { + t.Fatalf("create session: %v", err) + } + return id +} + +// init 确保每个 test 函数执行前 DB 足够干净 +func init() { + // 空 —— setupTestDB 在每个测试函数内调用 +} diff --git a/gateway/internal/repo/platform_sessions.go b/gateway/internal/repo/platform_sessions.go index 8b199d8..a9edbcc 100644 --- a/gateway/internal/repo/platform_sessions.go +++ b/gateway/internal/repo/platform_sessions.go @@ -8,6 +8,7 @@ import ( "time" "github.com/agentmail/gateway/internal/db" + "github.com/agentmail/gateway/internal/models" "github.com/google/uuid" ) @@ -320,6 +321,14 @@ func AdoptPlatformSession(ctx context.Context, agentName, platformID, slug, work platformID, id); err != nil { return uuid.Nil, err } + // 显式写入档位与强制力:接管一条平台会话没有父会话, + // 只靠 DB 默认值会在「schema 列定义变动」或「迁移补列给了不同默认」时 + // 静默偏离预期 —— 显式写 'workspace' 是唯一可靠表述「这条会话是新接管的, + // 没有继承来源」的方式。与 me.go 新建会话那条路径一致。 + if _, err := SetSessionPermissionMode(ctx, id, models.DefaultPermissionMode); err != nil { + return uuid.Nil, err + } + _ = SetSessionEnforcement(ctx, id, AgentModeEnforcement(ctx, agentName)) // 别名尽量用 slug;撞名时 EnsureSessionAlias 自动加后缀 _, _ = EnsureSessionAlias(ctx, id, slug) return id, nil diff --git a/gateway/internal/scheduler/calendar.go b/gateway/internal/scheduler/calendar.go index 0355bea..3bac0dd 100644 --- a/gateway/internal/scheduler/calendar.go +++ b/gateway/internal/scheduler/calendar.go @@ -165,7 +165,7 @@ func fireEvent(ctx context.Context, e models.CalendarEvent) { // 一起发:首个是主收件人,其余进 cc_list —— 所有人共享同一条线索, // 能看到彼此的回复。适合「pi 主办、dsh 知情」这种有主次的协作。 primary, cc := recipients[0], recipients[1:] - if err := SendCalendarMail(ctx, e.EventID, primary, subject, body, e.CreatedBy, cc...); err != nil { + if err := SendCalendarMail(ctx, e.EventID, primary, subject, body, e.CreatedBy, e.PermissionMode, cc...); err != nil { log.Printf("[scheduler] 投递提醒失败(%s → %s +%d抄送): %v", e.Title, primary, len(cc), err) } else { @@ -182,7 +182,7 @@ func fireEvent(ctx context.Context, e models.CalendarEvent) { // 另外两个仍该收到提醒。 ok, failed := 0, 0 for _, addr := range recipients { - if err := SendCalendarMail(ctx, e.EventID, addr, subject, body, e.CreatedBy); err != nil { + if err := SendCalendarMail(ctx, e.EventID, addr, subject, body, e.CreatedBy, e.PermissionMode); err != nil { failed++ log.Printf("[scheduler] 投递提醒失败(%s → %s): %v", e.Title, addr, err) continue @@ -231,7 +231,7 @@ func short(id string) string { // - `agent@/path` → 指定工作目录的默认会话 // - `agent@/path.alias` → 指定已存在的会话(不存在则报错,不静默新建) // - `agent@/path.new` → 每次提醒开一条新会话(适合互不相关的一次性任务) -func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, createdBy string, ccAddrs ...string) error { +func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, createdBy, permMode string, ccAddrs ...string) error { addr, err := models.ParseAddress(toAddr) if err != nil { return fmt.Errorf("收件地址 %q 无法解析: %w", toAddr, err) @@ -255,7 +255,7 @@ func SendCalendarMail(ctx context.Context, eventID, toAddr, subject, body, creat ccList = append(ccList, ca) } - sessionID, err := resolveCalendarSession(ctx, addr, subject) + sessionID, err := resolveCalendarSession(ctx, addr, subject, permMode) if err != nil { return err } @@ -335,7 +335,27 @@ const calendarSender = "calendar" // // 与 handler.resolveTarget 同一套三态语义,但**不受新建会话速率限制**: // 那条限制是防 Agent 暴开线索的,而日历事件的数量由人在界面上决定。 -func resolveCalendarSession(ctx context.Context, addr models.Address, subject string) (uuid.UUID, error) { +// +// 档位规则(见 PLAN 7.11 P1): +// - 新建会话 → 用事件档位定死(permMode,已规范化) +// - 复用已有会话 → ModeAtMost(会话现档, 事件档),取更严, +// 不允许因复用而提权(plan 档 Agent 建的日程触发时拿 workspace 就绕开了 plan) +func resolveCalendarSession(ctx context.Context, addr models.Address, subject, permMode string) (uuid.UUID, error) { + // permMode 由调用方已规范化过,这里再保一次(直接调用本函数的路径上该一样) + eventMode := models.NormalizePermissionMode(permMode) + apply := func(sessionID uuid.UUID, created bool) { + if created { + _, _ = repo.SetSessionPermissionMode(ctx, sessionID, eventMode) + _ = repo.SetSessionEnforcement(ctx, sessionID, repo.AgentModeEnforcement(ctx, addr.Name)) + return + } + // 复用已有会话:取更严。ModeAtMost 已判过会话现档与事件档,取严的那个。 + cur := repo.SessionPermissionMode(ctx, sessionID) + merged := models.ModeAtMost(cur, eventMode) + if merged != cur { + _, _ = repo.SetSessionPermissionMode(ctx, sessionID, merged) + } + } switch addr.Mode() { case models.SessionNew: id, err := repo.CreateSession(ctx, nil, calendarSender, subject, addr.Path) @@ -345,6 +365,7 @@ func resolveCalendarSession(ctx context.Context, addr models.Address, subject st // 与发信路径一致:`.new` 建完必须立刻有别名,否则这条会话 // 除了回复那一封之外再也无法寻址(未命名会话查不到也补全不出来)。 _, _ = repo.EnsureSessionAlias(ctx, id, repo.AutoAliasFor(addr.Name, subject)) + apply(id, true) return id, nil case models.SessionNamed: @@ -358,14 +379,16 @@ func resolveCalendarSession(ctx context.Context, addr models.Address, subject st return uuid.Nil, err } repo.TouchSession(ctx, id) + apply(id, false) return id, nil default: // SessionDefault - id, err := repo.FindOrCreateDefaultSession(ctx, addr.Name, addr.Path, calendarSender, subject) + id, created, err := repo.FindOrCreateDefaultSessionCreated(ctx, addr.Name, addr.Path, calendarSender, subject) if err != nil { return uuid.Nil, err } _, _ = repo.EnsureSessionAlias(ctx, id, repo.AutoAliasFor(addr.Name, subject)) + apply(id, created) return id, nil } }