package main import ( "log/slog" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/sessionread" ) // wireSessionReadTool registers the design/22 §8-3 cross-session read tool, so the // running role can pull another Web session's context as external, read-only // material (design/22 §8.1). // // The capability is registered with its default reachability markers (both false), // which keeps it owner-only: the design/11 §3 policy engine hides the schema from a // group/stranger session and the loop's security gate rejects the call there. The // tool's own fail-closed check (owner environment + the web/web: whitelist) // runs behind that gate, so an unauthorized read is refused twice over. The // audit trail is the loop's gate/OnAudit, exactly as for every other tool — no // bypass path is added here. func (a *app) wireSessionReadTool() { log := logger.WithSource(a.log, "cmd/cyrene") if a.history == nil { log.Warn("session read tool skipped: no history store") return } if a.dispatcher == nil { log.Warn("session read tool skipped: no tool dispatcher") return } reader := sessionread.New( sessionread.WithHistory(a.history), sessionread.WithPersonas(sessionReadPersonas{reg: a.personas}), sessionread.WithLocation(a.sessionReadLocation(log)), sessionread.WithLogger(a.log), ) if err := a.dispatcher.Register(reader); err != nil { log.Warn("session read tool register skipped", "error", err) return } log.Info("session read tool registered", "tool", sessionread.ToolName, "owner_only", true) } // sessionReadLocation resolves the configured IANA zone for the material's // timestamps, so the external transcript reads in the same clock as the loop's // per-round "current time" injection (internal/runtime/loop/time.go). An unset zone // or one that fails to load returns nil, which keeps the tool's process-local // default — the read must not fail over a display setting. func (a *app) sessionReadLocation(log *slog.Logger) *time.Location { if a.cfg == nil || a.cfg.Timezone == "" { return nil } loc, err := time.LoadLocation(a.cfg.Timezone) if err != nil { log.Warn("session read tool timezone load failed; using the process-local zone", "tz", a.cfg.Timezone, "error", err) return nil } return loc } // sessionReadPersonas adapts the persona registry to sessionread.PersonaNamer. The // tool package deliberately does not import internal/persona/registry (it holds a // narrow interface instead, keeping the layering one-way), so the composition root // supplies the display-name lookup. type sessionReadPersonas struct { reg *personareg.Registry } // PersonaName implements sessionread.PersonaNamer: the persona's display name, or // ok=false for an unknown/unregistered role so the tool shows the raw id rather // than inventing a name. func (p sessionReadPersonas) PersonaName(id string) (string, bool) { if p.reg == nil || id == "" { return "", false } persona, ok := p.reg.Persona(id) if !ok || persona == nil || persona.Name == "" { return "", false } return persona.Name, true } // DefaultPersonaID implements sessionread.PersonaNamer: the persona bound to the // library's default character, mirroring (*app).defaultPersonaID. An empty id means // the material renders the explicit "no role set" marker instead of guessing. func (p sessionReadPersonas) DefaultPersonaID() string { if p.reg == nil { return "" } if c, ok := p.reg.DefaultCharacter(); ok && c != nil { return c.PersonaID } return "" }