| 1 | //! What of a session each viewer sees. |
| 2 | //! |
| 3 | //! A session records someone's own work as it happens: what they typed to |
| 4 | //! their agent, the commands it ran, what came back. On a public |
| 5 | //! repository anyone may read the repository, but only the workspace's |
| 6 | //! members see inside its sessions; everyone else sees that one runs, who |
| 7 | //! runs it and what it is tied to ([`Sight::Outside`]). Members see what it |
| 8 | //! did, never the prompts someone else typed or the output a tool gave |
| 9 | //! them ([`Sight::Member`]), and a title, the team's one line on it, without |
| 10 | //! swearing. Whoever ran it sees it all ([`Sight::Own`]). |
| 11 | //! |
| 12 | //! Credentials are taken out of everything as it is recorded ([`clean`]), |
| 13 | //! and again as it is read, for what was recorded before that. |
| 14 | |
| 15 | use g1t_contracts::Viewer; |
| 16 | use g1t_contracts::work::{Session, SessionDetail, SessionEntry, SessionEntryKind, SessionUpdate}; |
| 17 | use g1t_scan::redact::{redact, soften}; |
| 18 | |
| 19 | /// How much of a session a viewer sees. |
| 20 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 21 | pub(crate) enum Sight { |
| 22 | /// Whoever ran it. |
| 23 | Own, |
| 24 | /// A member of the repository's workspace. |
| 25 | Member, |
| 26 | /// Anyone else who may read the repository. |
| 27 | Outside, |
| 28 | } |
| 29 | |
| 30 | impl Sight { |
| 31 | pub(crate) fn of(viewer: &Viewer, namespace: &str, actor_id: &str) -> Sight { |
| 32 | match viewer { |
| 33 | Some(viewer) if viewer.id == actor_id => Sight::Own, |
| 34 | Some(viewer) if viewer.is_member(&namespace.to_lowercase()) => Sight::Member, |
| 35 | _ => Sight::Outside, |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | /// Text as it is kept: credentials out. |
| 41 | pub(crate) fn clean(text: &str) -> String { |
| 42 | redact(text) |
| 43 | } |
| 44 | |
| 45 | /// [`clean`], for a field that may be absent. |
| 46 | pub(crate) fn clean_opt(text: Option<String>) -> Option<String> { |
| 47 | text.map(|text| redact(&text)).filter(|text| !text.trim().is_empty()) |
| 48 | } |
| 49 | |
| 50 | /// A session as `sight` sees it. |
| 51 | pub(crate) fn session(session: &mut Session, sight: Sight) { |
| 52 | match sight { |
| 53 | Sight::Own | Sight::Member => { |
| 54 | session.title = match sight { |
| 55 | Sight::Own => clean_opt(session.title.take()), |
| 56 | _ => session.title.take().map(|title| soften(&title)).filter(|title| !title.is_empty()), |
| 57 | }; |
| 58 | // Words typed by a person: theirs alone. |
| 59 | session.first_prompt = match sight { |
| 60 | Sight::Own => clean_opt(session.first_prompt.take()), |
| 61 | _ => None, |
| 62 | }; |
| 63 | session.task = clean_opt(session.task.take()); |
| 64 | session.prompt = clean_opt(session.prompt.take()); |
| 65 | session.result = clean_opt(session.result.take()); |
| 66 | session.end_reason = clean_opt(session.end_reason.take()); |
| 67 | } |
| 68 | Sight::Outside => { |
| 69 | session.title = None; |
| 70 | session.first_prompt = None; |
| 71 | session.task = None; |
| 72 | session.prompt = None; |
| 73 | session.result = None; |
| 74 | session.model = None; |
| 75 | session.branch = None; |
| 76 | session.head_commit = None; |
| 77 | session.harness_session_id = None; |
| 78 | session.agent_type = None; |
| 79 | session.end_reason = session.end_reason.take().filter(|reason| reason == "timed_out"); |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | /// An entry as `sight` sees it; `None` when it is not shown at all. |
| 85 | pub(crate) fn entry(mut entry: SessionEntry, sight: Sight) -> Option<SessionEntry> { |
| 86 | match sight { |
| 87 | Sight::Outside => None, |
| 88 | Sight::Member if matches!(entry.kind, SessionEntryKind::Prompt | SessionEntryKind::ToolResult) => { |
| 89 | entry.text = String::new(); |
| 90 | entry.withheld = true; |
| 91 | Some(entry) |
| 92 | } |
| 93 | _ => { |
| 94 | entry.text = redact(&entry.text); |
| 95 | Some(entry) |
| 96 | } |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | /// Every string in an update's data, credentials out. |
| 101 | fn clean_value(value: &mut serde_json::Value) { |
| 102 | match value { |
| 103 | serde_json::Value::String(text) => *text = redact(text), |
| 104 | serde_json::Value::Array(items) => items.iter_mut().for_each(clean_value), |
| 105 | serde_json::Value::Object(fields) => fields.values_mut().for_each(clean_value), |
| 106 | _ => {} |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// What an update may say of another session that only members see. |
| 111 | const MEMBERS_ONLY: &[&str] = &["title", "first_prompt", "prompt", "task", "result", "branch", "head_commit", "harness_session_id", "model", "agent_type", "end_reason"]; |
| 112 | |
| 113 | /// An update's data without what only members see: what other sessions |
| 114 | /// are titled, were asked, reported and run on. |
| 115 | fn outside_value(value: &mut serde_json::Value) { |
| 116 | match value { |
| 117 | serde_json::Value::Array(items) => items.iter_mut().for_each(outside_value), |
| 118 | serde_json::Value::Object(fields) => { |
| 119 | for key in MEMBERS_ONLY { |
| 120 | if fields.get(*key).is_some_and(|value| !value.is_null()) { |
| 121 | fields.insert((*key).to_owned(), serde_json::Value::Null); |
| 122 | } |
| 123 | } |
| 124 | fields.values_mut().for_each(outside_value); |
| 125 | } |
| 126 | _ => {} |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /// An update as its session's agent reads it: credentials out, and for |
| 131 | /// someone outside the workspace (on a public repository, their own |
| 132 | /// session hears of members' sessions) only what an outsider may see. |
| 133 | fn update(mut update: SessionUpdate, member: bool) -> SessionUpdate { |
| 134 | clean_value(&mut update.data); |
| 135 | if !member { |
| 136 | outside_value(&mut update.data); |
| 137 | } |
| 138 | update |
| 139 | } |
| 140 | |
| 141 | /// A session's page as `viewer` sees it, on a repository of `namespace`. |
| 142 | pub(crate) fn detail(mut detail: SessionDetail, viewer: &Viewer, namespace: &str) -> SessionDetail { |
| 143 | let sight = Sight::of(viewer, namespace, &detail.session.actor.id); |
| 144 | session(&mut detail.session, sight); |
| 145 | detail.entries = detail.entries.into_iter().filter_map(|one| entry(one, sight)).collect(); |
| 146 | for child in &mut detail.children { |
| 147 | session(child, Sight::of(viewer, namespace, &child.actor.id)); |
| 148 | } |
| 149 | if let Some(root) = &mut detail.root { |
| 150 | session(root, Sight::of(viewer, namespace, &root.actor.id)); |
| 151 | } |
| 152 | if sight == Sight::Outside { |
| 153 | detail.touched.clear(); |
| 154 | detail.skills.clear(); |
| 155 | detail.updates.clear(); |
| 156 | detail.children.clear(); |
| 157 | detail.limited = true; |
| 158 | } else { |
| 159 | let member = viewer.as_ref().is_some_and(|viewer| viewer.is_member(&namespace.to_lowercase())); |
| 160 | detail.updates = detail.updates.into_iter().map(|one| update(one, member)).collect(); |
| 161 | } |
| 162 | detail |
| 163 | } |
| 164 | |
| 165 | /// Sessions in a list, each as `viewer` sees it. |
| 166 | pub(crate) fn sessions(sessions: &mut [Session], viewer: &Viewer, namespace: &str) { |
| 167 | for one in sessions { |
| 168 | let sight = Sight::of(viewer, namespace, &one.actor.id); |
| 169 | session(one, sight); |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | #[cfg(test)] |
| 174 | mod tests { |
| 175 | use super::*; |
| 176 | use g1t_contracts::{Membership, User}; |
| 177 | use g1t_contracts::credentials::Principal; |
| 178 | use g1t_contracts::work::{Harness, SessionState}; |
| 179 | |
| 180 | fn person(id: &str, workspaces: &[&str]) -> Viewer { |
| 181 | Some(User { |
| 182 | id: id.into(), |
| 183 | username: id.into(), |
| 184 | workspaces: workspaces.iter().map(|slug| Membership::member(slug.to_string())).collect(), |
| 185 | ..User::default() |
| 186 | }) |
| 187 | } |
| 188 | |
| 189 | fn a_session() -> Session { |
| 190 | Session { |
| 191 | id: "ses_1".into(), |
| 192 | repo_id: "rep_1".into(), |
| 193 | issue: Some(4), |
| 194 | pull: None, |
| 195 | branch: Some("secret-plans".into()), |
| 196 | actor: Principal { id: "usr_a".into(), username: "a".into() }, |
| 197 | harness: Harness::ClaudeCode, |
| 198 | harness_session_id: Some("d1485".into()), |
| 199 | title: Some("Fix this fucking login with G1T_TOKEN=abc123def456".into()), |
| 200 | state: SessionState::Active, |
| 201 | head_commit: Some("58def40".into()), |
| 202 | started_at: String::new(), |
| 203 | last_seen_at: String::new(), |
| 204 | ended_at: None, |
| 205 | end_reason: None, |
| 206 | parent_id: None, |
| 207 | agent_type: None, |
| 208 | task: None, |
| 209 | prompt: None, |
| 210 | result: Some("done; token ghp_A1b2C3d4E5f6G7h8I9j0".into()), |
| 211 | model: Some("claude-opus-5-5".into()), |
| 212 | subagents: 0, |
| 213 | first_prompt: Some("ugh fix it".into()), |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | fn an_entry(kind: SessionEntryKind, text: &str) -> SessionEntry { |
| 218 | SessionEntry { seq: 1, kind, text: text.into(), tool: None, commit: None, at: String::new(), withheld: false } |
| 219 | } |
| 220 | |
| 221 | #[test] |
| 222 | fn who_sees_what() { |
| 223 | assert_eq!(Sight::of(&person("usr_a", &[]), "acme", "usr_a"), Sight::Own); |
| 224 | assert_eq!(Sight::of(&person("usr_b", &["acme"]), "Acme", "usr_a"), Sight::Member); |
| 225 | assert_eq!(Sight::of(&person("usr_c", &["other"]), "acme", "usr_a"), Sight::Outside); |
| 226 | assert_eq!(Sight::of(&None, "acme", "usr_a"), Sight::Outside); |
| 227 | } |
| 228 | |
| 229 | #[test] |
| 230 | fn an_outsider_sees_that_it_runs_and_nothing_inside() { |
| 231 | let mut one = a_session(); |
| 232 | session(&mut one, Sight::Outside); |
| 233 | assert_eq!(one.title, None); |
| 234 | assert_eq!(one.first_prompt, None); |
| 235 | assert_eq!(one.result, None); |
| 236 | assert_eq!(one.branch, None); |
| 237 | assert_eq!(one.model, None); |
| 238 | assert_eq!(one.issue, Some(4)); |
| 239 | assert_eq!(one.actor.username, "a"); |
| 240 | for kind in [SessionEntryKind::Prompt, SessionEntryKind::ToolCall, SessionEntryKind::Note, SessionEntryKind::Message] { |
| 241 | assert!(entry(an_entry(kind, "x"), Sight::Outside).is_none()); |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | #[test] |
| 246 | fn a_member_sees_the_work_not_someone_elses_words() { |
| 247 | let mut one = a_session(); |
| 248 | session(&mut one, Sight::Member); |
| 249 | assert_eq!(one.title.as_deref(), Some("Fix this login with G1T_TOKEN=[redacted]")); |
| 250 | assert_eq!(one.first_prompt, None); |
| 251 | assert_eq!(one.result.as_deref(), Some("done; token ghp_[redacted]")); |
| 252 | let prompt = entry(an_entry(SessionEntryKind::Prompt, "wtf, fix it"), Sight::Member).unwrap(); |
| 253 | assert!(prompt.withheld && prompt.text.is_empty()); |
| 254 | let output = entry(an_entry(SessionEntryKind::ToolResult, "AWS_SECRET=x"), Sight::Member).unwrap(); |
| 255 | assert!(output.withheld); |
| 256 | let call = entry(an_entry(SessionEntryKind::ToolCall, "bash: curl -H 'Authorization: Bearer abcdefgh1234'"), Sight::Member).unwrap(); |
| 257 | assert!(!call.withheld && !call.text.contains("abcdefgh1234")); |
| 258 | } |
| 259 | |
| 260 | #[test] |
| 261 | fn an_outsiders_own_session_hears_of_members_sessions_as_an_outsider() { |
| 262 | let data = serde_json::json!({"session": {"title": "Rotate keys", "prompt": "secret plan", "id": "ses_2", "actor": {"username": "b"}}, "files": ["a.rs"]}); |
| 263 | let one = SessionUpdate { id: "upd_1".into(), kind: "overlap.detected".into(), data, at: String::new() }; |
| 264 | let shown = update(one.clone(), false); |
| 265 | assert_eq!(shown.data["session"]["title"], serde_json::Value::Null); |
| 266 | assert_eq!(shown.data["session"]["prompt"], serde_json::Value::Null); |
| 267 | assert_eq!(shown.data["session"]["id"], "ses_2"); |
| 268 | assert_eq!(shown.data["files"][0], "a.rs"); |
| 269 | assert_eq!(update(one, true).data["session"]["title"], "Rotate keys"); |
| 270 | } |
| 271 | |
| 272 | #[test] |
| 273 | fn its_author_sees_it_all_without_credentials() { |
| 274 | let mut one = a_session(); |
| 275 | session(&mut one, Sight::Own); |
| 276 | assert_eq!(one.title.as_deref(), Some("Fix this fucking login with G1T_TOKEN=[redacted]")); |
| 277 | assert_eq!(one.first_prompt.as_deref(), Some("ugh fix it")); |
| 278 | let prompt = entry(an_entry(SessionEntryKind::Prompt, "use G1T_TOKEN=abc123def456"), Sight::Own).unwrap(); |
| 279 | assert!(!prompt.withheld); |
| 280 | assert_eq!(prompt.text, "use G1T_TOKEN=[redacted]"); |
| 281 | } |
| 282 | } |