Files
Cyrene_v2/internal/runtime/sessionread/reader.go
T
AskaEth 08b3a01752
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(runtime,ai): session_read — 跨会话只读「主动读取」能力(design/22 §8-3/§8.1)
主人 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 侧成功路径钩子需先定口径。
2026-10-03 21:33:40 +08:00

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
}