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 侧成功路径钩子需先定口径。
This commit is contained in:
1 parent
05d34853f0
commit
08b3a01752
11 files changed
+1723
No files matched your search
@@ -0,0 +1,195 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user