主人 2026-10-03 过目 design/22 §8.1 草案并点头后实现。
- 新包 internal/runtime/sessionread:工具壳 + 只读取数 + 归属排版(按职责拆 8 文件)
- 只读由类型保证:注入的 HistoryReader 只声明 List/ListSessions —— 本包结构上无法写入
任何会话、也推进不了目标会话的 read_at
- fail-closed 授权,顺序 = 环境 → 白名单 → 存在性:
非 owner 环境拒(ErrNotOwner)→ 白名单只许 web / web:<uuid>(ErrSessionNotAllowed)
→ 会话不存在是硬错误(ErrSessionNotFound,绝不返回空材料,防"读不到"被当成"对方没说")
- 排版:头部三行恒定(外部会话材料 · 只读 · 不属于你的记忆 / 来源角色 / 来源会话)
+ 时间跨度与翻页游标 + 逐条「[时间] 说话人:」归属
- 说话人取消息自身 persona_id;'' → 全局默认角色;未知 id 原样显示;user → SenderName(空=主人)
- Execute 返回 string:loop 的 formatToolResult 对 string 原样透传,换行不被 json 转义
- 接线 cmd/cyrene/wire.go 一行 + wireSessionReadTool():能力默认标记(双 false)= owner-only,
不新增任何 bypass 路径;审计仍走 loop 的 gate
- 日志 source=runtime/sessionread,只记 actor/target/count/limit/before/has_older,不含正文
验证:go build/vet ./... 绿;internal/runtime/sessionread(25 测试)+ cmd/cyrene 绿;
linux/amd64 + darwin/arm64 交叉编译绿;
TestWireSessionReadToolRegistersOwnerOnly 断言 owner 放行 / group_member / stranger 拒绝
遗留(登记,未解决):§8.1 约束 4 要求"每次调用落审计",但 loop 的 gate 只在拒绝时发
AuditEvent ⇒ 成功读取目前只有日志可追溯,未进审计库;补 loop 侧成功路径钩子需先定口径。
196 lines
7.8 KiB
Go
196 lines
7.8 KiB
Go
package sessionread
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
)
|
|
|
|
// Page bounds of the read. They match the REST page (GET /sessions/{id}/messages,
|
|
// history.clampLimit) so a tool page and a client page are the same size, with the
|
|
// default and the cap applied here rather than rejected as bad arguments.
|
|
const (
|
|
DefaultLimit = 50
|
|
MaxLimit = 200
|
|
)
|
|
|
|
// Sentinels distinguishing the fail-closed refusals from a genuine failure. Each
|
|
// case has its own message so the model — and the operator reading the log — can
|
|
// tell "not allowed" from "does not exist", which design/22 §8.1 约束 3 requires.
|
|
var (
|
|
// ErrNoHistory is returned when no read-only history view was injected.
|
|
ErrNoHistory = errors.New("sessionread: no session-history reader injected")
|
|
|
|
// ErrNotOwner is returned when the calling conversation is not the owner's.
|
|
ErrNotOwner = errors.New("sessionread: only the owner conversation may read another session")
|
|
|
|
// ErrSessionNotAllowed is returned when the target session id is outside the
|
|
// readable whitelist (Web main session and Web sub sessions only).
|
|
ErrSessionNotAllowed = errors.New("sessionread: target session is not readable (only web / web:<uuid>)")
|
|
|
|
// ErrSessionNotFound is returned when the target session does not exist. It is
|
|
// deliberately an error, never empty material: "read nothing" must not be
|
|
// mistakable for "they said nothing".
|
|
ErrSessionNotFound = errors.New("sessionread: target session does not exist")
|
|
)
|
|
|
|
// HistoryReader is the read-only session-history view this package needs. It is
|
|
// deliberately narrower than history.HistoryStore: it declares no append, no
|
|
// delete and no read-cursor call, so from inside this package a message cannot be
|
|
// written to any session and the target session's read_at cannot be advanced
|
|
// (design/22 §8.1 约束 1).
|
|
type HistoryReader interface {
|
|
// List returns up to limit messages of sessionID, newest first, bounded to the
|
|
// messages strictly older than before when before > 0 (design/16 §3.1).
|
|
List(ctx context.Context, sessionID string, before int64, limit int, platform string) ([]history.Message, error)
|
|
// ListSessions returns every session's metadata (id, title, role).
|
|
ListSessions(ctx context.Context) ([]history.SessionMeta, error)
|
|
}
|
|
|
|
// PersonaNamer resolves role ids to display names. The package keeps this a narrow
|
|
// interface on purpose: importing internal/persona/registry here would couple the
|
|
// runtime to the persona loader, so the composition root supplies an adapter.
|
|
type PersonaNamer interface {
|
|
// PersonaName returns the display name of role id. ok is false for a role this
|
|
// runtime does not know, in which case the raw id is shown instead of dropping
|
|
// the attribution.
|
|
PersonaName(id string) (string, bool)
|
|
// DefaultPersonaID returns the global default role id, or "" when none is set.
|
|
// An unset role id on a message or session means "the default role".
|
|
DefaultPersonaID() string
|
|
}
|
|
|
|
// noPersonaNames is the fallback namer: it knows no role names, so the material
|
|
// shows raw ids rather than inventing a display name.
|
|
type noPersonaNames struct{}
|
|
|
|
// PersonaName implements PersonaNamer.
|
|
func (noPersonaNames) PersonaName(string) (string, bool) { return "", false }
|
|
|
|
// DefaultPersonaID implements PersonaNamer.
|
|
func (noPersonaNames) DefaultPersonaID() string { return "" }
|
|
|
|
// Request is one session-read request after argument decoding.
|
|
type Request struct {
|
|
// SessionID is the target session ("web" or "web:<uuid>").
|
|
SessionID string
|
|
// Limit caps the page; 0 or less means DefaultLimit, above MaxLimit is clamped.
|
|
Limit int
|
|
// Before is the millisecond cursor: only messages strictly older than it are
|
|
// returned. 0 or less means the newest page.
|
|
Before int64
|
|
}
|
|
|
|
// Reader performs the fail-closed authorization and the read, then renders the
|
|
// material. It holds no per-call state and is safe for concurrent use.
|
|
type Reader struct {
|
|
history HistoryReader
|
|
personas PersonaNamer
|
|
loc *time.Location
|
|
log *slog.Logger
|
|
}
|
|
|
|
// Read authorizes the call, reads one page of the target session and returns the
|
|
// formatted external material.
|
|
//
|
|
// The checks run in this order deliberately: the caller's environment first (a
|
|
// non-owner conversation learns nothing about any session), the id whitelist
|
|
// second (a session outside web/web:<uuid> is refused even if it exists), and only
|
|
// then the existence lookup.
|
|
func (r *Reader) Read(ctx context.Context, req Request) (string, error) {
|
|
if r.history == nil {
|
|
return "", ErrNoHistory
|
|
}
|
|
env := loop.SessionEnvFrom(ctx)
|
|
if err := r.authorize(env, req.SessionID); err != nil {
|
|
return "", err
|
|
}
|
|
meta, err := r.sessionMeta(ctx, req.SessionID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
limit := clampLimit(req.Limit)
|
|
msgs, err := r.history.List(ctx, req.SessionID, req.Before, limit, "")
|
|
if err != nil {
|
|
return "", fmt.Errorf("sessionread: list %s: %w", req.SessionID, err)
|
|
}
|
|
m := material{
|
|
sessionID: req.SessionID,
|
|
title: meta.Title,
|
|
personaID: meta.PersonaID,
|
|
before: req.Before,
|
|
msgs: msgs,
|
|
hasOlder: len(msgs) == limit,
|
|
}
|
|
// Who read what, and how much — the provenance of design/22 §8.1 约束 4 — is
|
|
// logged here; the message bodies are not. The audit record itself stays with the
|
|
// loop's gate/OnAudit, which this tool neither writes to nor bypasses.
|
|
r.log.Info("external session material read",
|
|
"actor", string(env.ActorEnv), "target", req.SessionID, "count", len(msgs),
|
|
"limit", limit, "before", req.Before, "has_older", m.hasOlder)
|
|
return r.render(m), nil
|
|
}
|
|
|
|
// authorize enforces the fail-closed whitelist: the calling conversation must be
|
|
// the owner's, and the target must be the Web main session or a Web sub session.
|
|
// Default deny, never default allow (design/22 §8.1 约束 3).
|
|
func (r *Reader) authorize(env loop.SessionEnv, sessionID string) error {
|
|
if env.ActorEnv != restrict.ActorOwner {
|
|
r.log.Warn("external session read refused", "target", sessionID, "env", string(env.ActorEnv))
|
|
return ErrNotOwner
|
|
}
|
|
if !ReadableSessionID(sessionID) {
|
|
r.log.Warn("external session read refused", "target", sessionID, "reason", "session not readable")
|
|
return ErrSessionNotAllowed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadableSessionID reports whether id is in the readable set: the Web main session
|
|
// or a Web sub session. It is the fail-closed whitelist of design/22 §8.1 约束 3 —
|
|
// IM group and stranger sessions live outside it and are exactly the ones that must
|
|
// never leak.
|
|
func ReadableSessionID(id string) bool {
|
|
if id == history.MainSessionID {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(id, history.MainSessionID+":") && len(id) > len(history.MainSessionID)+1
|
|
}
|
|
|
|
// sessionMeta looks the session up in the session list, so a session that does not
|
|
// exist is a hard error rather than empty material. The list read is a single
|
|
// parameter-free query the store already serves for the session list page.
|
|
func (r *Reader) sessionMeta(ctx context.Context, sessionID string) (history.SessionMeta, error) {
|
|
sessions, err := r.history.ListSessions(ctx)
|
|
if err != nil {
|
|
return history.SessionMeta{}, fmt.Errorf("sessionread: list sessions: %w", err)
|
|
}
|
|
for _, meta := range sessions {
|
|
if meta.ID == sessionID {
|
|
return meta, nil
|
|
}
|
|
}
|
|
return history.SessionMeta{}, fmt.Errorf("sessionread: session %q: %w", sessionID, ErrSessionNotFound)
|
|
}
|
|
|
|
// clampLimit bounds a requested page size to [DefaultLimit, MaxLimit], mirroring
|
|
// history.clampLimit so a tool page and a client page are the same size. A
|
|
// non-positive request (an absent argument, a non-numeric one, or a negative one)
|
|
// means DefaultLimit; a request above the cap is clamped, not rejected.
|
|
func clampLimit(limit int) int {
|
|
if limit < 1 {
|
|
return DefaultLimit
|
|
}
|
|
if limit > MaxLimit {
|
|
return MaxLimit
|
|
}
|
|
return limit
|
|
}
|