package main // report.go — `privatae-bastion report [--since 7d] [--json] [--out FILE|-]` // and the console's Write report: what this machine's bastion knows, as one // Markdown file a person can read, keep, or hand to someone (plan Addendum 4, // step 5; M0-T5). No model writes any of it: every sentence is built here // from what the bastion already reads, the same builders the console uses. // // Sections: the agents running now; scheduled jobs that start an agent; MCP // tool servers and their problems; what runs that the baseline does not // hold; what happened in the window. A summary of the problems leads it. // // Left out on purpose: each session's last prompt (what a person typed into // another session), and any secret: tool-server commands and scheduled // commands are redacted before they get here. import ( "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "strings" "time" ) type reportBaseline struct { Has bool `json:"has"` TakenAt string `json:"taken_at,omitempty"` Note string `json:"note,omitempty"` Compared bool `json:"compared"` Why string `json:"why_not_compared,omitempty"` Findings []string `json:"findings,omitempty"` } type machineReport struct { Machine string `json:"machine"` Generated time.Time `json:"generated"` Since time.Time `json:"since"` Version string `json:"bastion_version"` Summary []string `json:"summary"` Agents []reportAgent `json:"agents"` Schedules []uiSchedule `json:"scheduled_agents"` SchedOK bool `json:"schedulers_read"` ToolServers []uiToolGroup `json:"tool_servers"` Baseline reportBaseline `json:"baseline"` Activity []activityItem `json:"activity"` } // reportAgent is one agent in the report, with whether it is marked as // yours. The mark is judged where the report is built, against the machine's // own registry (sanction.go): a report read through the owner's lane // (reads.go) is printed by an account that holds no registry, and judging it // there listed every agent as not marked as yours under a summary that // counted the machine's marks. type reportAgent struct { agentView NotYours bool `json:"not_marked_yours,omitempty"` } func buildReport(now, since time.Time) machineReport { hn, _ := os.Hostname() r := machineReport{Machine: hn, Generated: now, Since: since, Version: clientVersion} if agents, err := observedAgents(); err == nil { for _, v := range buildAgentViews(agents, processTree()) { if v.Self != nil { v.Self.LastPrompt = "" } v.DelegationToken = "" // a shared secret for the Host, as readAgents leaves it out a := reportAgent{agentView: v} if v.Role != "service" && v.Kind != kindApp { ok, _ := agentSanction(v.agentObs) a.NotYours = !ok } r.Agents = append(r.Agents, a) } } r.Schedules, r.SchedOK = collectSchedules() r.ToolServers = collectToolGroups() if f, ok, err := baselineLoad(); err == nil { r.Baseline.Has, r.Baseline.TakenAt, r.Baseline.Note = true, f.TakenAt, baselineIntegrityNote(f, ok) if machineAuthorityHeld() { rep := deltaPass(&digestCache{}, now, deltaRoleOperator) if rep.WalkFailed != "" { r.Baseline.Why = "the process walk failed: " + rep.WalkFailed } else { r.Baseline.Compared = true for _, ev := range rep.Findings { r.Baseline.Findings = append(r.Baseline.Findings, ev.Level+": "+deltaOneLine(ev)) } } } else { r.Baseline.Why = "comparing needs the machine's administrator; run the report with sudo" } } r.Activity = collectActivity(since) r.Summary = reportSummary(r) return r } // reportSummary is the problems first, in counts a person can act on. func reportSummary(r machineReport) []string { var s []string sessions, notYours, promptsOff, waiting := 0, 0, 0, 0 for _, a := range r.Agents { if a.Role == "service" || a.Kind != kindAgent { continue } sessions++ if a.NotYours { notYours++ } if a.Self != nil && a.Self.PermissionMode == "bypassPermissions" { promptsOff++ } if a.Self != nil && a.Self.WaitingFor != "" { waiting++ } } line := fmt.Sprintf("%s running", plural(sessions, "AI agent session", "AI agent sessions")) var extra []string if notYours > 0 { extra = append(extra, fmt.Sprintf("%d not marked as yours", notYours)) } if promptsOff > 0 { extra = append(extra, fmt.Sprintf("%d with permission prompts off", promptsOff)) } if waiting > 0 { extra = append(extra, fmt.Sprintf("%d waiting for a person", waiting)) } if len(extra) > 0 { line += ": " + strings.Join(extra, ", ") } s = append(s, line+".") if len(r.Schedules) > 0 { s = append(s, plural(len(r.Schedules), "scheduled job starts", "scheduled jobs start")+" an AI agent with nobody at the keyboard.") } plain, unpinned := 0, 0 for _, g := range r.ToolServers { for _, t := range g.Servers { for _, p := range t.Problems { switch { case strings.Contains(p, "plain text"): plain++ case strings.Contains(p, "no fixed version"): unpinned++ } } } } if plain > 0 { s = append(s, plural(plain, "MCP tool server has", "MCP tool servers have")+" a key written in plain text.") } if unpinned > 0 { s = append(s, plural(unpinned, "MCP tool server runs", "MCP tool servers run")+" a package with no fixed version.") } switch { case !r.Baseline.Has: s = append(s, "No baseline is recorded, so nothing new on this machine can be told apart from what was always there.") case r.Baseline.Compared && len(r.Baseline.Findings) > 0: s = append(s, plural(len(r.Baseline.Findings), "thing runs", "things run")+" that the baseline does not hold.") case r.Baseline.Compared: s = append(s, "Everything running is in the baseline.") } return s } func renderReportMarkdown(w io.Writer, r machineReport) { p := func(format string, a ...any) { fmt.Fprintf(w, format, a...) } p("# AI agents on %s\n\n", mdText(r.Machine)) p("Written %s by privatae-bastion %s. Activity covers %s to now. No AI model wrote any of this report.\n\n", r.Generated.Format("2006-01-02 15:04 MST"), mdText(r.Version), r.Since.Format("2006-01-02 15:04")) p("## Summary\n\n") for _, l := range r.Summary { p("- %s\n", mdText(l)) } p("\n## Agents running now\n\n") if len(r.Agents) == 0 { p("None.\n") } for _, a := range r.Agents { if a.Role == "service" { p("- **%s background service** (pid %d): starts background sessions; not a session itself.\n", mdText(familyLabel(a.Signature)), a.PID) continue } l := agentLinesOf(a.agentView, r.Generated) p("- **%s**: %s\n", mdText(familyLabel(a.Signature)), mdText(l.Head)) if l.State != "" { p(" - %s\n", mdText(l.State)) } if l.Where != "" { p(" - %s · pid %d\n", mdText(l.Where), a.PID) } if len(a.RunningNow) > 0 { p(" - running now: `%s`\n", mdCode(strings.Join(a.RunningNow, "; "))) } if len(a.ToolServers) > 0 { p(" - tool servers: %s\n", mdText(strings.Join(a.ToolServers, ", "))) } if len(a.LLMEndpoints) > 0 { p(" - talks to: %s\n", mdText(strings.Join(a.LLMEndpoints, ", "))) } if a.NotYours { p(" - not marked as yours\n") } } p("\nNames, titles and statuses are what each agent says about itself.\n") p("\n## Scheduled jobs that start an agent\n\n") switch { case !r.SchedOK: p("This platform's schedulers could not be read, so this section is empty because nothing looked.\n") case len(r.Schedules) == 0: p("None.\n") } for _, s := range r.Schedules { p("- **%s** %s, from %s", mdText(s.Agent), mdText(s.Schedule), mdText(s.Where)) if s.User != "" { p(", as %s", mdText(s.User)) } p(": `%s`\n", mdCode(s.Command)) } p("\n## MCP tool servers\n\n") if len(r.ToolServers) == 0 { p("None configured in any settings file this bastion can read.\n") } for _, g := range r.ToolServers { p("- **%s**: %s\n", mdText(g.Client), plural(len(g.Servers), "tool server", "tool servers")) for _, t := range g.Servers { if len(t.Problems) == 0 { p(" - %s: no problems found\n", mdText(t.Name)) continue } for _, pr := range t.Problems { p(" - %s: %s\n", mdText(t.Name), mdText(pr)) } } } p("\n## Changes since the baseline\n\n") switch { case !r.Baseline.Has: p("No baseline is recorded. Record one on the console's This machine page, or with " + "`sudo -H privatae-bastion observe:baseline`.\n") default: p("Baseline recorded %s.", mdText(r.Baseline.TakenAt)) if r.Baseline.Note != "" { p(" **%s**", mdText(r.Baseline.Note)) } p("\n\n") if !r.Baseline.Compared { p("Not compared: %s.\n", mdText(r.Baseline.Why)) } else if len(r.Baseline.Findings) == 0 { p("Nothing running is outside the baseline.\n") } for _, f := range r.Baseline.Findings { p("- %s\n", mdText(f)) } } p("\n## Activity\n\n") if len(r.Activity) == 0 { p("Nothing happened in this period.\n") } for _, it := range r.Activity { who := "" if it.Who != "" { who = it.Who + " " } refused := "" if it.Refused { refused = " (refused)" } p("- %s: %s%s\n", it.At.Local().Format("Jan 2 15:04"), mdText(who+it.What), refused) } } // mdText keeps agent- and file-written text from becoming Markdown: no // links, images, emphasis or HTML can be smuggled in through a session title. func mdText(s string) string { r := strings.NewReplacer(`\`, `\\`, "`", "\\`", "*", `\*`, "_", `\_`, "[", `\[`, "]", `\]`, "<", "<", ">", ">", "!", `\!`, "#", `\#`, "|", `\|`) return r.Replace(cleanText(s, 400)) } func mdCode(s string) string { return strings.ReplaceAll(cleanText(s, 400), "`", "'") } const reportUsage = "usage: privatae-bastion report [--since 7d] [--json] [--out FILE | --out -]" // readReport is the report itself, built where the machine's state is. func readReport(o readOpts) (machineReport, error) { since, err := o.since(7 * 24 * time.Hour) if err != nil { return machineReport{}, err } now := time.Now() return buildReport(now, now.Add(-since)), nil } func cmdReport(args []string) error { sinceFlag, asJSON, out := "", false, "" for i := 0; i < len(args); i++ { switch { case args[i] == "--json": asJSON = true case args[i] == "--since" && i+1 < len(args): if _, err := parseSince(args[i+1]); err != nil { return err } sinceFlag, i = args[i+1], i+1 case args[i] == "--out" && i+1 < len(args): out, i = args[i+1], i+1 default: return errors.New(reportUsage) } } r, err := readMachine("report", readOpts{Since: sinceFlag}, readReport) if err != nil { return laneRefusalError("report", err) } if out == "-" { // To standard output, for an agent that reads it rather than keeps it. if asJSON { return printJSON(os.Stdout, r) } renderReportMarkdown(os.Stdout, r) return nil } now := r.Generated.Local() if out == "" { d, err := configDir() if err != nil { return err } ext := ".md" if asJSON { ext = ".json" } out = filepath.Join(d, "reports", "report-"+now.Format("2006-01-02-150405")+ext) } if err := os.MkdirAll(filepath.Dir(out), 0o700); err != nil { return err } fh, err := os.OpenFile(out, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) if err != nil { return err } if asJSON { enc := json.NewEncoder(fh) enc.SetIndent("", " ") err = enc.Encode(r) } else { renderReportMarkdown(fh, r) } if cerr := fh.Close(); err == nil { err = cerr } if err != nil { return err } fmt.Println("report written to", out) return nil }