Sessions show by who is looking, and nothing that looks like a credential is kept: on a public repository anyone could read every prompt, command and subagent report a session recorded (coordination_repo only asked whether they could read the repository). Now whoever ran a session sees all of it; a member of the workspace sees what it did (title, tool calls, messages, notes, skills, files, subagents) but not the prompts someone else typed nor a tool's output, which show as kept back (SessionEntry.withheld), and a title without swearing (soften); anyone else sees that it runs, who runs it, its harness, state and times and the issues and pull requests it is tied to, nothing it recorded, touched, was asked or reported, nor its title, branch or model (SessionDetail.limited). The same holds for lists (Live now, Work), a pull request's session (outsiders see only g1t's notes; prompts and tool output only to their author, by the session that recorded them) and its sessions list's prompt (members only). Pending updates are shaped too: an outsider's own session on a public repository hears of members' sessions without their titles, prompts, tasks, results, branches or models.
Credentials: crates/scan/src/redact.rs replaces with [redacted] every format secret scanning knows, prefixed tokens (g1t_, ghp_, sk-ant-, whsec_, npm_ … with a letter and a digit), NAME_TOKEN=value and password: value (references like $TOKEN, ${{ secrets.X }}, process.env.X and types like token: String are left), --token/--password flags, curl -u, Authorization and Bearer headers, a URL's user:password and signed query parameters, JWTs, PEM private keys, and long random-looking strings. The work service applies it as it records (entries, titles, tasks, prompts, results, a pull request's record_session) and again as it reads, for what was recorded before. The Claude Code plugin (0.2.9) does the same before anything leaves the machine: prompts, titles, subagents' prompts, tasks and results, not only command lines as before; its old redact missed G1T_TOKEN=… (the digit) and $env: assignments. Cloud sessions: a Managed Agents session's page has Open in Claude Console for whoever started it (integrations agent_session_link, which tells anyone else, or another repository, there is no such session); the Console shows everything the session was asked and every tool's output, so nobody else gets the link. Its page-side half went out in 74236dee, which needs this commit to typecheck.
- Docs:
- the sessions guide's Who sees what, and the plugin, bring-your-own-agent and why-blame guides no longer say a session is as visible as its repository. Deploy: work, integrations, api, the site; the plugin release. Checked: cargo test -p g1t-scan (72; new: redact), -p g1t-work (214; new: shown), -p g1t-contracts (319), -p g1t-integrations agent_sessions (11); cargo check -p g1t-api; wasm32 check of work and integrations; node --test plugins/claude-code/test/hooks.test.mjs (59; new: credentials wherever text is sent); apps/web tsc -b; npm test (987, 1 skipped).
- G1t-Session:
- ses_01m501jq3vetja2s4a904rs068
| 7 | 7 | "name": "g1t", | |
| 8 | 8 | "source": "./plugins/claude-code", | |
| 9 | 9 | "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.", | |
| 10 | − | "version": "0.2.8", | |
| 10 | + | "version": "0.2.9", | |
| 11 | 11 | "category": "development" | |
| 12 | 12 | } | |
| 13 | 13 | ] |
| 1646 | 1646 | "g1t-contracts", | |
| 1647 | 1647 | "g1t-kit", | |
| 1648 | 1648 | "g1t-rules", | |
| 1649 | + | "g1t-scan", | |
| 1649 | 1650 | "getrandom 0.2.17", | |
| 1650 | 1651 | "hex", | |
| 1651 | 1652 | "rusqlite", |
| 301 | 301 | } | |
| 302 | 302 | ||
| 303 | 303 | fn entry(seq: u32, kind: SessionEntryKind, text: &str, commit: Option<&str>) -> SessionEntry { | |
| 304 | − | SessionEntry { seq, kind, text: text.into(), tool: None, commit: commit.map(String::from), at: String::new() } | |
| 304 | + | SessionEntry { seq, kind, text: text.into(), tool: None, commit: commit.map(String::from), at: String::new(), withheld: false } | |
| 305 | 305 | } | |
| 306 | 306 | ||
| 307 | 307 | #[test] |
| 180 | 180 | (`prompt`, `message`, `tool_call`, `tool_result` or `note`) and `text`, | |
| 181 | 181 | and tool entries also carry the `tool` name. Record before each push: an | |
| 182 | 182 | entry is tied to the commit that was the head when it was recorded. Do not | |
| 183 | − | put secrets in a session; sessions are as visible as the repository. | |
| 183 | + | put secrets in a session: g1t takes out what looks like a credential, and | |
| 184 | + | shows what a session recorded only to the workspace's members (a prompt | |
| 185 | + | only to whoever typed it), but that is a safety net. See | |
| 186 | + | [Who sees what](/guides/sessions/#who-sees-what). | |
| 184 | 187 | ||
| 185 | 188 | ### Recording sessions automatically | |
| 186 | 189 |
| 87 | 87 | ## What it records | |
| 88 | 88 | ||
| 89 | 89 | The plugin's hooks call the `g1t` CLI, signed in as you, so a session is | |
| 90 | − | recorded as yours and is as visible as the repository it is in. | |
| 90 | + | recorded as yours. Before anything is sent, the plugin takes out what | |
| 91 | + | looks like a credential (`[redacted]`) from command lines, prompts, | |
| 92 | + | titles, and what subagents were asked and reported; g1t does it again as | |
| 93 | + | it records. Your prompts show only to you; the workspace's members see the | |
| 94 | + | rest, and anyone else who can read the repository sees only that the | |
| 95 | + | session runs ([Who sees what](/guides/sessions/#who-sees-what)). | |
| 91 | 96 | ||
| 92 | 97 | | When | The hook runs | What g1t receives | | |
| 93 | 98 | | --- | --- | --- | |
| 160 | 160 | on it with `record_session`, so reviewers and why-blame see them. Entries | |
| 161 | 161 | recorded before the session named its pull request move onto it when it | |
| 162 | 162 | does. A session never records tool output unless you send it as a | |
| 163 | − | `tool_result` entry; a session is as visible as the repository, so keep | |
| 164 | − | secrets out of it. | |
| 163 | + | `tool_result` entry. What looks like a credential is taken out of | |
| 164 | + | everything a session records, and only the workspace's members see inside | |
| 165 | + | a session: see [Who sees what](#who-sees-what). | |
| 165 | 166 | ||
| 166 | 167 | List a repository's sessions with `session` `list`, or | |
| 167 | 168 | [`GET /repos/{owner}/{name}/sessions`](/reference/api/sessions/list-sessions/) | |
| ⋯ | |||
| 346 | 347 | report. g1t follows it every minute, and at once while its page is open. A | |
| 347 | 348 | call that waits for someone's approval is declined, since nobody is | |
| 348 | 349 | watching. When it finishes, its session ends with its last message as its | |
| 349 | − | result. Whoever started it can stop it from its page. It runs on your | |
| 350 | + | result. Whoever started it can stop it from its page, and open it on the | |
| 351 | + | Claude Console from there (**Open in Claude Console**); nobody else is | |
| 352 | + | given that link, since the Console shows everything the session was asked | |
| 353 | + | and every tool's output. It runs on your | |
| 350 | 354 | workspace's key and Anthropic bills the account for it; g1t bills nothing. | |
| 351 | 355 | ||
| 352 | 356 | ## Assign g1t, or ask it in the thread | |
| ⋯ | |||
| 503 | 507 | the repository's sessions or claims are next read or changed, not at the | |
| 504 | 508 | minute it lapsed. | |
| 505 | 509 | ||
| 510 | + | ## Who sees what | |
| 511 | + | ||
| 512 | + | A session records someone's own work as it happens, so g1t shows it by who | |
| 513 | + | is looking, on the site, the API, MCP and the CLI alike. | |
| 514 | + | ||
| 515 | + | | Who | Sees | | |
| 516 | + | | --- | --- | | |
| 517 | + | | Whoever ran it | All of it: its prompts, tool calls, what it touched and reported, its pending updates. | | |
| 518 | + | | A member of the repository's workspace | What it did: its title, tool calls, messages, notes, skills, what it touched, its subagents' tasks, prompts and reports, its pending updates. Not the prompts someone else typed, nor a tool's output (`tool_result`): those entries show as kept back. A title shows without swearing. | | |
| 519 | + | | Anyone else who may read the repository | That it runs: who runs it, in which harness, since when, its state and the issues and pull requests it is tied to. Nothing it recorded, touched, was asked or reported, nor its title, branch or model. On a pull request's session, only g1t's own notes. | | |
| 520 | + | ||
| 521 | + | Before anything is kept, g1t takes out what looks like a credential | |
| 522 | + | (`[redacted]`): every token format [secret protection](/guides/security/secret-protection/) | |
| 523 | + | knows, any key or token by its issuer's prefix, `NAME_TOKEN=value`, | |
| 524 | + | `password: value` and the like, `--token`/`--password` flags, `Bearer` and | |
| 525 | + | `Authorization` headers, a URL's user and password and its signed query | |
| 526 | + | parameters, JWTs, private keys, and long random-looking strings. What was | |
| 527 | + | recorded before this is hidden the same way as it is read. The | |
| 528 | + | [Claude Code plugin](/guides/claude-code-plugin/) does the same before | |
| 529 | + | anything leaves your machine. It is a safety net, not a vault: keep | |
| 530 | + | secrets out of prompts and command lines. | |
| 531 | + | ||
| 506 | 532 | ## Who may do what | |
| 507 | 533 | ||
| 508 | 534 | | To | You need | | |
| 509 | 535 | | --- | --- | | |
| 510 | − | | Read sessions, claims and an issue's context | To be able to read the repository. Public repositories need no token. | | |
| 536 | + | | Read sessions, claims and an issue's context | To be able to read the repository. Public repositories need no token. What a session shows depends on who you are: see [Who sees what](#who-sees-what). | | |
| 511 | 537 | | Start a session, claim an issue, open one by claiming it | A confirmed email address and a role that may open issues; the repository must not be archived. | | |
| 512 | 538 | | Update, record in or end a session | To be the person whose token started it. | | |
| 513 | 539 | | Release a claim or hand it off | To be the person who made it. | | |
| 27 | 27 | ||
| 28 | 28 | Read a session on the pull request's **Session** tab, with the | |
| 29 | 29 | `pull_request` tool's `read_session` action, | |
| 30 | − | or with `GET /repos/{owner}/{name}/pulls/{number}/session?after=`. A | |
| 31 | − | session is as visible as the repository, so do not put secrets in one. | |
| 30 | + | or with `GET /repos/{owner}/{name}/pulls/{number}/session?after=`. | |
| 31 | + | Outside the workspace only g1t's notes show there, and members see a | |
| 32 | + | prompt or a tool's output only when it is their own; what looks like a | |
| 33 | + | credential is taken out of all of it | |
| 34 | + | ([Who sees what](/guides/sessions/#who-sees-what)). Still, do not put | |
| 35 | + | secrets in one. | |
| 32 | 36 | ||
| 33 | 37 | ### Recording a session | |
| 34 | 38 |
| 32 | 32 | ); | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | + | /** A prompt someone else typed: that there was one, not what it said. */ | |
| 36 | + | function KeptPrompt({ entry }: { entry: SessionEntry }) { | |
| 37 | + | return ( | |
| 38 | + | <li className="flex items-center gap-2 px-4 py-3"> | |
| 39 | + | <Terminal size={14} className="shrink-0 text-faint" /> | |
| 40 | + | <span className="shrink-0 font-medium text-fg-soft">Prompt</span> | |
| 41 | + | <span className="min-w-0 grow truncate text-xs text-faint">Shows only to whoever typed it.</span> | |
| 42 | + | <At at={entry.at} /> | |
| 43 | + | </li> | |
| 44 | + | ); | |
| 45 | + | } | |
| 46 | + | ||
| 35 | 47 | /** The prompt, folded under its first line. */ | |
| 36 | 48 | function Prompt({ entry, base }: { entry: SessionEntry; base?: string }) { | |
| 37 | 49 | const [open, setOpen] = useState(false); | |
| ⋯ | |||
| 119 | 131 | </CollapsibleTrigger> | |
| 120 | 132 | <CollapsibleContent> | |
| 121 | 133 | {long && call && <Block>{call.detail}</Block>} | |
| 122 | − | {step.results.map((result) => ( | |
| 123 | − | <Block key={result.seq}>{result.text}</Block> | |
| 124 | − | ))} | |
| 134 | + | {step.results.map((result) => | |
| 135 | + | result.withheld ? ( | |
| 136 | + | <p key={result.seq} className="text-xs text-faint"> | |
| 137 | + | Its output shows only to whoever ran it. | |
| 138 | + | </p> | |
| 139 | + | ) : ( | |
| 140 | + | <Block key={result.seq}>{result.text}</Block> | |
| 141 | + | ), | |
| 142 | + | )} | |
| 125 | 143 | </CollapsibleContent> | |
| 126 | 144 | </Collapsible> | |
| 127 | 145 | ) : ( | |
| ⋯ | |||
| 176 | 194 | } | |
| 177 | 195 | ||
| 178 | 196 | function Entry({ entry, base }: { entry: SessionEntry; base?: string }) { | |
| 179 | − | if (entry.kind === "prompt") return <Prompt entry={entry} base={base} />; | |
| 197 | + | if (entry.kind === "prompt") return entry.withheld ? <KeptPrompt entry={entry} /> : <Prompt entry={entry} base={base} />; | |
| 180 | 198 | if (entry.kind === "message") { | |
| 181 | 199 | return ( | |
| 182 | 200 | <li className="flex gap-2.5 px-4 py-3 text-sm"> | |
| 162 | 162 | pub id: String, | |
| 163 | 163 | } | |
| 164 | 164 | ||
| 165 | + | /// `agent_session_link`: where whoever started a Managed Agents session | |
| 166 | + | /// opens it on the Anthropic account it runs on (the Claude Console). | |
| 167 | + | /// Nobody else is given it: the session's events hold what it was asked | |
| 168 | + | /// and every tool's output, which g1t shows no one but them. Returns | |
| 169 | + | /// `Outcome<String>`. | |
| 170 | + | #[derive(Debug, Serialize, Deserialize)] | |
| 171 | + | pub struct AgentSessionLinkArgs { | |
| 172 | + | pub actor: User, | |
| 173 | + | pub repo: RepoPath, | |
| 174 | + | /// The g1t session (`ses_…`) at the top of its tree. | |
| 175 | + | pub id: String, | |
| 176 | + | } | |
| 177 | + | ||
| 165 | 178 | /// `stop_agent_session`: interrupts a Managed Agents session and ends its | |
| 166 | 179 | /// record. Only whoever started it. Returns `Outcome<bool>`. | |
| 167 | 180 | #[derive(Debug, Serialize, Deserialize)] |
| 1007 | 1007 | pub commit: Option<String>, | |
| 1008 | 1008 | /// RFC 3339. | |
| 1009 | 1009 | pub at: String, | |
| 1010 | + | /// Its text is not shown to this viewer: a prompt is its author's own, | |
| 1011 | + | /// and a tool's output stays with whoever ran it. `text` is empty. | |
| 1012 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] | |
| 1013 | + | pub withheld: bool, | |
| 1010 | 1014 | } | |
| 1011 | 1015 | ||
| 1012 | 1016 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 766 | 766 | /// For a subagent: the session at the top of its tree. | |
| 767 | 767 | #[serde(default)] | |
| 768 | 768 | pub root: Option<Session>, | |
| 769 | + | /// The viewer is not a member of the repository's workspace: they see | |
| 770 | + | /// that the session runs, who runs it and what it is tied to, and | |
| 771 | + | /// nothing of what it recorded, touched or was asked. | |
| 772 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] | |
| 773 | + | pub limited: bool, | |
| 769 | 774 | } | |
| 770 | 775 | ||
| 771 | 776 | /// `pull_sessions`: the harness sessions tied to some of a repository's |
| 13 | 13 | pub mod osv; | |
| 14 | 14 | pub mod pack; | |
| 15 | 15 | pub mod protection; | |
| 16 | + | pub mod redact; | |
| 16 | 17 | pub mod review; | |
| 17 | 18 | pub mod sarif; | |
| 18 | 19 | pub mod sbom; |
| 1 | + | //! Hiding credentials in text people and agents write about their work: a | |
| 2 | + | //! session's prompts, tool calls and messages, its title and what it | |
| 3 | + | //! reported. Unlike [`crate::secrets`], which must be sure before it stops | |
| 4 | + | //! a push, this errs the other way: a word too many hidden costs nothing, | |
| 5 | + | //! a key shown on a public page costs the key. | |
| 6 | + | //! | |
| 7 | + | //! [`redact`] replaces what looks like a credential with [`REDACTED`]: | |
| 8 | + | //! every format [`crate::secrets`] knows, prefixed tokens of any length, | |
| 9 | + | //! `NAME_TOKEN=value` and `password: value`, credential flags, bearer | |
| 10 | + | //! headers, a URL's user and password and its signed query parameters, | |
| 11 | + | //! JWTs, PEM private keys, and long random-looking strings. [`soften`] | |
| 12 | + | //! takes swearing out of a title, so that how someone spoke to their own | |
| 13 | + | //! agent is not what their team reads. | |
| 14 | + | ||
| 15 | + | use std::sync::LazyLock; | |
| 16 | + | ||
| 17 | + | use regex::{Captures, Regex}; | |
| 18 | + | ||
| 19 | + | /// What a credential is replaced with. | |
| 20 | + | pub const REDACTED: &str = "[redacted]"; | |
| 21 | + | ||
| 22 | + | fn re(pattern: &str) -> Regex { | |
| 23 | + | Regex::new(pattern).expect("a redaction pattern") | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// A key or token by its issuer's prefix: `ghp_…`, `sk-ant-…`, `g1t_…`. | |
| 27 | + | static PREFIXED: LazyLock<Regex> = LazyLock::new(|| { | |
| 28 | + | re(r"\b(g1t|ghp|gho|ghu|ghs|ghr|github_pat|glpat|gloas|glrt|glptt|gldt|xox[abeprs]|sk-ant|sk-proj|sk-svcacct|sk-admin|sk|rk|whsec|npm|hf|pypi|dop_v1|shpat|shpss|sntrys|lin_api|figd)([-_])([A-Za-z0-9_-]{12,})") | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | /// `AKIA…`, `AIza…`, `SG.….…`: formats whose prefix needs no separator. | |
| 32 | + | static BARE: LazyLock<Regex> = | |
| 33 | + | LazyLock::new(|| re(r"\b(?:(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{35}|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,})\b")); | |
| 34 | + | ||
| 35 | + | /// `Authorization: Bearer …`, `Proxy-Authorization: Basic …`. | |
| 36 | + | static AUTH_HEADER: LazyLock<Regex> = | |
| 37 | + | LazyLock::new(|| re(r"(?i)\b((?:proxy-)?authorization\s*[:=]\s*(?:(?:bearer|basic|token|bot|digest)\s+)?)[^\s\x22'`,;]+")); | |
| 38 | + | ||
| 39 | + | /// `Bearer …` anywhere else. | |
| 40 | + | static BEARER: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b(bearer\s+)[A-Za-z0-9._~+/=-]{8,}")); | |
| 41 | + | ||
| 42 | + | /// `G1T_TOKEN=…`, `$env:API_KEY='…'`, `"password": "…"`, `client_secret: …`. | |
| 43 | + | static ASSIGNED: LazyLock<Regex> = LazyLock::new(|| { | |
| 44 | + | re(r#"(?i)([A-Za-z0-9_.-]*(?:token|secret|passw(?:or)?d|pwd|api[_-]?key|apikey|private[_-]?key|access[_-]?key|auth[_-]?key|signing[_-]?key|session[_-]?key|credentials?|webhook[_-]?url|dsn|connection[_-]?string)["']?\s*(?:=|:)\s*)("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|[^\s,;&)}\]]+)"#) | |
| 45 | + | }); | |
| 46 | + | ||
| 47 | + | /// `--token abc`, `--password=abc`, `--api-key abc`. | |
| 48 | + | static FLAG: LazyLock<Regex> = LazyLock::new(|| { | |
| 49 | + | re(r"(?i)(--(?:token|password|passwd|secret|api-key|apikey|access-token|auth-token|client-secret|private-key)(?:=|\s+))(\x22[^\x22]*\x22|'[^']*'|\S+)") | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | /// `curl -u name:password`. | |
| 53 | + | static USER_FLAG: LazyLock<Regex> = LazyLock::new(|| re(r"((?:^|\s)(?:-u|--user)\s+[^\s:]+:)\S+")); | |
| 54 | + | ||
| 55 | + | /// `https://name:password@host`. | |
| 56 | + | static USERINFO: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b([a-z][a-z0-9+.-]*://)[^\s/:@]+:[^\s/@]+@")); | |
| 57 | + | ||
| 58 | + | /// `?token=…`, `&X-Amz-Signature=…`. | |
| 59 | + | static QUERY: LazyLock<Regex> = LazyLock::new(|| { | |
| 60 | + | re(r"(?i)([?&](?:access_token|refresh_token|id_token|token|key|api_key|apikey|secret|client_secret|sig|signature|password|code|x-amz-signature|x-amz-credential|x-amz-security-token)=)[^&\s#\x22'`]+") | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | /// A JSON Web Token. | |
| 64 | + | static JWT: LazyLock<Regex> = LazyLock::new(|| re(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}")); | |
| 65 | + | ||
| 66 | + | /// A PEM private key, to its end or to the end of the text. | |
| 67 | + | static PEM: LazyLock<Regex> = | |
| 68 | + | LazyLock::new(|| re(r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\z)")); | |
| 69 | + | ||
| 70 | + | /// A long run that may be random: judged by [`random_looking`]. | |
| 71 | + | static LONG: LazyLock<Regex> = LazyLock::new(|| re(r"[A-Za-z0-9+/_=-]{32,}")); | |
| 72 | + | ||
| 73 | + | /// A value that names a secret rather than holding one: `$TOKEN`, | |
| 74 | + | /// `${{ secrets.X }}`, `process.env.X`, or one already hidden. | |
| 75 | + | fn reference(value: &str) -> bool { | |
| 76 | + | let value = value.trim_matches(['"', '\'', '`']); | |
| 77 | + | value.is_empty() | |
| 78 | + | || value.starts_with('$') | |
| 79 | + | || value.starts_with('%') | |
| 80 | + | || value.starts_with('<') | |
| 81 | + | || value.contains("secrets.") | |
| 82 | + | || value.contains("env.") | |
| 83 | + | || value.contains("[redacted") | |
| 84 | + | || value == "…" | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | fn shannon(text: &str) -> f64 { | |
| 88 | + | let mut counts = std::collections::HashMap::new(); | |
| 89 | + | for c in text.chars() { | |
| 90 | + | *counts.entry(c).or_insert(0usize) += 1; | |
| 91 | + | } | |
| 92 | + | let length = text.chars().count() as f64; | |
| 93 | + | counts | |
| 94 | + | .values() | |
| 95 | + | .map(|count| { | |
| 96 | + | let p = *count as f64 / length; | |
| 97 | + | -p * p.log2() | |
| 98 | + | }) | |
| 99 | + | .sum() | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /// Upper case, lower case and digits mixed with the spread of a random | |
| 103 | + | /// string: a key with no prefix. A commit (lowercase hex), a path, a | |
| 104 | + | /// CamelCase name or a word run together does not look like this. | |
| 105 | + | fn random_looking(run: &str) -> bool { | |
| 106 | + | let digits = run.chars().filter(char::is_ascii_digit).count(); | |
| 107 | + | run.chars().any(|c| c.is_ascii_uppercase()) | |
| 108 | + | && run.chars().any(|c| c.is_ascii_lowercase()) | |
| 109 | + | && digits >= 3 | |
| 110 | + | && !run.contains("//") | |
| 111 | + | && shannon(run) >= 4.3 | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | fn has_letter_and_digit(body: &str) -> bool { | |
| 115 | + | body.chars().any(|c| c.is_ascii_alphabetic()) && body.chars().any(|c| c.is_ascii_digit()) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// `text` with what looks like a credential replaced by [`REDACTED`]. | |
| 119 | + | pub fn redact(text: &str) -> String { | |
| 120 | + | if text.is_empty() { | |
| 121 | + | return String::new(); | |
| 122 | + | } | |
| 123 | + | // Exact formats first, whatever surrounds them. | |
| 124 | + | let mut out = text.to_owned(); | |
| 125 | + | let mut values: Vec<String> = crate::secrets::scan_text(text).into_iter().map(|hit| hit.value).collect(); | |
| 126 | + | values.sort_by_key(|value| std::cmp::Reverse(value.len())); | |
| 127 | + | for value in values { | |
| 128 | + | // A private key's value is its header and the start of its body. | |
| 129 | + | let value = value.split('\n').next_back().unwrap_or(&value).to_owned(); | |
| 130 | + | if value.len() >= 8 { | |
| 131 | + | out = out.replace(&value, REDACTED); | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | let out = PEM.replace_all(&out, REDACTED); | |
| 135 | + | let out = JWT.replace_all(&out, REDACTED); | |
| 136 | + | let out = PREFIXED.replace_all(&out, |c: &Captures| { | |
| 137 | + | if has_letter_and_digit(&c[3]) { format!("{}{}{REDACTED}", &c[1], &c[2]) } else { c[0].to_owned() } | |
| 138 | + | }); | |
| 139 | + | let out = BARE.replace_all(&out, REDACTED); | |
| 140 | + | let out = AUTH_HEADER.replace_all(&out, |c: &Captures| { | |
| 141 | + | let value = &c[0][c[1].len()..]; | |
| 142 | + | if reference(value) { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) } | |
| 143 | + | }); | |
| 144 | + | let out = BEARER.replace_all(&out, |c: &Captures| { | |
| 145 | + | let value = &c[0][c[1].len()..]; | |
| 146 | + | if reference(value) { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) } | |
| 147 | + | }); | |
| 148 | + | let out = USERINFO.replace_all(&out, format!("${{1}}{REDACTED}@").as_str()); | |
| 149 | + | let out = QUERY.replace_all(&out, format!("${{1}}{REDACTED}").as_str()); | |
| 150 | + | let out = FLAG.replace_all(&out, |c: &Captures| { | |
| 151 | + | if reference(&c[2]) { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) } | |
| 152 | + | }); | |
| 153 | + | let out = USER_FLAG.replace_all(&out, format!("${{1}}{REDACTED}").as_str()); | |
| 154 | + | let out = ASSIGNED.replace_all(&out, |c: &Captures| { | |
| 155 | + | let value = &c[2]; | |
| 156 | + | // `token: string` in code is a type, not a token. | |
| 157 | + | let typed = matches!(value, "string" | "String" | "str" | "&str" | "number" | "bool" | "boolean" | "null" | "None" | "undefined" | "true" | "false" | "Option<String>"); | |
| 158 | + | if reference(value) || typed { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) } | |
| 159 | + | }); | |
| 160 | + | let out = LONG.replace_all(&out, |c: &Captures| { | |
| 161 | + | if random_looking(&c[0]) { REDACTED.to_owned() } else { c[0].to_owned() } | |
| 162 | + | }); | |
| 163 | + | out.into_owned() | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// Words that say how someone felt, not what they asked for. | |
| 167 | + | const SWEARING: &[&str] = &[ | |
| 168 | + | "fuck", "fucking", "fucked", "fucker", "fuckin", "motherfucker", "motherfucking", "shit", "shitty", "bullshit", "horseshit", | |
| 169 | + | "damn", "damned", "dammit", "damnit", "goddamn", "goddamned", "goddammit", "wtf", "wth", "ffs", "stfu", "omfg", "asshole", | |
| 170 | + | "bitch", "bitching", "crap", "crappy", "idiot", "idiotic", "moron", "moronic", "dumbass", "retard", "retarded", "bastard", | |
| 171 | + | "pissed", "jfc", "fml", | |
| 172 | + | ]; | |
| 173 | + | ||
| 174 | + | fn swearing(word: &str) -> bool { | |
| 175 | + | let bare: String = word.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_lowercase(); | |
| 176 | + | !bare.is_empty() && (SWEARING.contains(&bare.as_str()) || bare.starts_with("fuck") || bare.starts_with("shit")) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | /// A title as the team reads it: credentials hidden and swearing left out. | |
| 180 | + | /// Empty when nothing else was said. | |
| 181 | + | pub fn soften(title: &str) -> String { | |
| 182 | + | let redacted = redact(title); | |
| 183 | + | let mut words: Vec<&str> = Vec::new(); | |
| 184 | + | for word in redacted.split_whitespace() { | |
| 185 | + | if swearing(word) { | |
| 186 | + | // Its punctuation stays with the sentence: "this, fucking thing" reads "this, thing". | |
| 187 | + | continue; | |
| 188 | + | } | |
| 189 | + | words.push(word); | |
| 190 | + | } | |
| 191 | + | let text = words.join(" "); | |
| 192 | + | let text = text.trim_matches(|c: char| c.is_whitespace() || matches!(c, ',' | ';' | ':' | '-')); | |
| 193 | + | let mut chars = text.chars(); | |
| 194 | + | match chars.next() { | |
| 195 | + | // A title that lost its first word still starts as a sentence. | |
| 196 | + | Some(first) if words.len() < redacted.split_whitespace().count() => first.to_uppercase().chain(chars).collect(), | |
| 197 | + | Some(_) => text.to_owned(), | |
| 198 | + | None => String::new(), | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | #[cfg(test)] | |
| 203 | + | mod tests { | |
| 204 | + | use super::*; | |
| 205 | + | ||
| 206 | + | fn hidden(text: &str, secret: &str) { | |
| 207 | + | let out = redact(text); | |
| 208 | + | assert!(!out.contains(secret), "{secret} shows in {out}"); | |
| 209 | + | assert!(out.contains(REDACTED), "nothing hidden in {out}"); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[test] | |
| 213 | + | fn tokens_by_their_prefix() { | |
| 214 | + | hidden("source ~/.bashrc; curl -H 'x' g1t_0123456789abcdef0123456789abcdef01234567", "0123456789abcdef0123"); | |
| 215 | + | hidden("use ghp_A1b2C3d4E5f6G7h8I9j0 please", "A1b2C3d4E5f6G7h8I9j0"); | |
| 216 | + | hidden("key sk-ant-api03-abcDEF123456ghiJKL", "abcDEF123456ghiJKL"); | |
| 217 | + | hidden("whsec_1234567890abcdefXYZ", "1234567890abcdefXYZ"); | |
| 218 | + | hidden("AKIAIOSFODNN7EXAMPLE was it", "IOSFODNN7EXAMPLE"); | |
| 219 | + | // Names that only share a prefix are left alone. | |
| 220 | + | assert_eq!(redact("npm_config_user_agent is set"), "npm_config_user_agent is set"); | |
| 221 | + | assert_eq!(redact("sk-learn-classifier"), "sk-learn-classifier"); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | #[test] | |
| 225 | + | fn assignments_flags_and_headers() { | |
| 226 | + | hidden("G1T_TOKEN=abc123def456 g1t whoami", "abc123def456"); | |
| 227 | + | hidden("$env:G1T_TOKEN='abc123def456'; g1t whoami", "abc123def456"); | |
| 228 | + | hidden(r#"{"password": "hunter2!"}"#, "hunter2"); | |
| 229 | + | hidden("client_secret: s3cr3tvalue", "s3cr3tvalue"); | |
| 230 | + | hidden("DATABASE_PASSWORD=\"correct horse\"", "correct horse"); | |
| 231 | + | hidden("g1t auth login --token abc123", "abc123"); | |
| 232 | + | hidden("x --password=pa55", "pa55"); | |
| 233 | + | hidden("curl -u admin:pa55word https://x", "pa55word"); | |
| 234 | + | hidden(r#"curl -H "Authorization: Bearer abc.def-123" https://api"#, "abc.def-123"); | |
| 235 | + | hidden("Authorization: Basic dXNlcjpwYXNz", "dXNlcjpwYXNz"); | |
| 236 | + | hidden("send it with bearer abcdefgh12345678", "abcdefgh12345678"); | |
| 237 | + | hidden("X-Api-Key: 1234abcd5678", "1234abcd5678"); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | #[test] | |
| 241 | + | fn references_and_types_are_not_secrets() { | |
| 242 | + | let line = "TOKEN=$G1T_TOKEN; token: ${{ secrets.DEPLOY }}; password: process.env.PW; fn f(token: String)"; | |
| 243 | + | assert_eq!(redact(line), line); | |
| 244 | + | assert_eq!(redact(r#"-H "Authorization: Bearer …""#), r#"-H "Authorization: Bearer …""#); | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | #[test] | |
| 248 | + | fn urls_jwts_and_keys() { | |
| 249 | + | hidden("git clone https://x-access-token:abc123@g1t.sh/a/b", "abc123"); | |
| 250 | + | hidden("postgres://app:pa55@db:5432/app", "pa55"); | |
| 251 | + | hidden("open https://x.com/cb?code=abc&state=1&token=t0k3n", "t0k3n"); | |
| 252 | + | hidden("GET /file?X-Amz-Signature=deadbeef1234&x=1", "deadbeef1234"); | |
| 253 | + | hidden("jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "dozjgNryP4J3"); | |
| 254 | + | let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ\n-----END OPENSSH PRIVATE KEY-----\nafter"; | |
| 255 | + | let out = redact(pem); | |
| 256 | + | assert!(!out.contains("b3BlbnNzaC1"), "{out}"); | |
| 257 | + | assert!(out.ends_with("after")); | |
| 258 | + | hidden("-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn", "MIIEpAIBAAKCAQEA"); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | #[test] | |
| 262 | + | fn random_strings_go_and_ordinary_ones_stay() { | |
| 263 | + | hidden("the key is Xk9fQ2mZr7Lp4Wv8Ty3Nb6Hc1Jd5Ge0S", "Xk9fQ2mZr7Lp4Wv8Ty3Nb6Hc1Jd5Ge0S"); | |
| 264 | + | for ordinary in [ | |
| 265 | + | "git show 58def4040e93a1dcda5f388f55360f3cd46cd97e", | |
| 266 | + | "services/work/src/coordination/sessions.rs", | |
| 267 | + | "CoordinationOp::StartAgentSession and ListWorkSessionsArgs", | |
| 268 | + | "MSYS_NO_PATHCONV=1 git show origin/40-g1t_upkeep:.g1t/upkeep.yml", | |
| 269 | + | "Fix the merge queue: it re-tests every pull request after a push", | |
| 270 | + | ] { | |
| 271 | + | assert_eq!(redact(ordinary), ordinary); | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | #[test] | |
| 276 | + | fn swearing_is_left_out_of_titles() { | |
| 277 | + | assert_eq!(soften("Fix this fucking bug"), "Fix this bug"); | |
| 278 | + | assert_eq!(soften("wtf, why is the PR page slow"), "Why is the PR page slow"); | |
| 279 | + | assert_eq!(soften("Make the shitty button work"), "Make the button work"); | |
| 280 | + | assert_eq!(soften("fuck"), ""); | |
| 281 | + | assert_eq!(soften("Shell scripts and hello world"), "Shell scripts and hello world"); | |
| 282 | + | assert_eq!(soften("Rotate G1T_TOKEN=abc123def456 now"), format!("Rotate G1T_TOKEN={REDACTED} now")); | |
| 283 | + | } | |
| 284 | + | } |
| 2 | 2 | * Starting work on an issue in a session on the person's own Claude. | |
| 3 | 3 | * Mirrors `crates/contracts/src/agent_sessions.rs` (the integrations | |
| 4 | 4 | * service's `start_agent_session`, `agent_sessions_setup`, | |
| 5 | − | * `sync_agent_session` and `stop_agent_session`). | |
| 5 | + | * `sync_agent_session`, `agent_session_link` and `stop_agent_session`). | |
| 6 | 6 | */ | |
| 7 | 7 | import type { ServiceBinding } from "./clients"; | |
| 8 | 8 | import type { User, Viewer } from "./identity"; | |
| ⋯ | |||
| 59 | 59 | start(actor: User, repo: RepoPath, input: StartAgentSessionInput): Promise<Result<AgentSessionStarted>>; | |
| 60 | 60 | /** Reads the session now (it is otherwise followed every minute). Whether it is still going. */ | |
| 61 | 61 | sync(repo: RepoPath, id: string, viewer: Viewer): Promise<Result<boolean>>; | |
| 62 | + | /** Where it opens on the Claude Console. Only whoever started it is told. */ | |
| 63 | + | link(actor: User, repo: RepoPath, id: string): Promise<Result<string>>; | |
| 62 | 64 | /** Interrupts it and ends its record. Only whoever started it. */ | |
| 63 | 65 | stop(actor: User, repo: RepoPath, id: string): Promise<Result<boolean>>; | |
| 64 | 66 | } | |
| ⋯ | |||
| 91 | 93 | handoff: input.handoff ?? null, | |
| 92 | 94 | }), | |
| 93 | 95 | sync: (repo, id, viewer) => call("sync_agent_session", { repo, id, viewer }), | |
| 96 | + | link: (actor, repo, id) => call("agent_session_link", { actor, repo, id }), | |
| 94 | 97 | stop: (actor, repo, id) => call("stop_agent_session", { actor, repo, id }), | |
| 95 | 98 | }; | |
| 96 | 99 | } | |
| 406 | 406 | commit: string | null; | |
| 407 | 407 | /** RFC 3339. */ | |
| 408 | 408 | at: string; | |
| 409 | + | /** | |
| 410 | + | * Its text is not shown to this viewer (`text` is empty): a prompt shows | |
| 411 | + | * only to whoever typed it, and a tool's output to whoever ran it. | |
| 412 | + | */ | |
| 413 | + | withheld?: boolean; | |
| 409 | 414 | }; | |
| 410 | 415 | ||
| 411 | 416 | export type NewSessionEntry = Pick<SessionEntry, "kind" | "text"> & | |
| ⋯ | |||
| 1551 | 1556 | skills?: SessionSkill[]; | |
| 1552 | 1557 | /** For a subagent: the session at the top of its tree. */ | |
| 1553 | 1558 | root?: WorkSession | null; | |
| 1559 | + | /** | |
| 1560 | + | * The viewer is not a member of the repository's workspace: the session | |
| 1561 | + | * shows that it runs, who runs it and what it is tied to, nothing more. | |
| 1562 | + | */ | |
| 1563 | + | limited?: boolean; | |
| 1554 | 1564 | }; | |
| 1555 | 1565 | ||
| 1556 | 1566 | /** Reading coordination: work service methods (services/work/src/coordination, decisions.rs). */ | |
| 1 | 1 | { | |
| 2 | 2 | "name": "g1t", | |
| 3 | − | "version": "0.2.8", | |
| 3 | + | "version": "0.2.9", | |
| 4 | 4 | "description": "Work on g1t issues from Claude Code: records your sessions on g1t, loads an issue's work context, claims and hands off work, and connects g1t's MCP server.", | |
| 5 | 5 | "author": { "name": "Flagon, Inc.", "url": "https://g1t.sh" }, | |
| 6 | 6 | "homepage": "https://docs.g1t.sh/guides/claude-code-plugin/", |
| 36 | 36 | // that is not a tool's output: an Agent call's answer, which is its | |
| 37 | 37 | // subagent's final message (as SubagentStop gives it) and its id. Prompts | |
| 38 | 38 | // are not sent when G1T_RECORD_PROMPTS=0, and nothing is when G1T_RECORD=0. | |
| 39 | + | // Whatever is sent has what looks like a credential taken out first | |
| 40 | + | // (redact): command lines, prompts, titles, subagents' prompts and results. | |
| 41 | + | // On g1t a prompt shows only to whoever typed it. | |
| 39 | 42 | // | |
| 40 | 43 | // A session that starts where there is no g1t remote is recorded from the | |
| 41 | 44 | // prompt, or the git or g1t command, after which there is one (lateStart). | |
| ⋯ | |||
| 108 | 111 | return value.length > limit ? `${value.slice(0, limit - 1)}…` : value; | |
| 109 | 112 | } | |
| 110 | 113 | ||
| 111 | − | /** Hides what looks like a secret in a command line before it is sent. */ | |
| 114 | + | /** What a credential is replaced with, as g1t itself does (crates/scan/src/redact.rs). */ | |
| 115 | + | export const REDACTED = "[redacted]"; | |
| 116 | + | ||
| 117 | + | /** A value that names a secret rather than holding one: `$TOKEN`, `${{ secrets.X }}`, `process.env.X`. */ | |
| 118 | + | function reference(value) { | |
| 119 | + | const v = String(value).replace(/^["'`]|["'`]$/g, ""); | |
| 120 | + | return !v || /^[$%<]/.test(v) || v.includes("secrets.") || v.includes("env.") || v.includes("[redacted") || v === "…"; | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /** `token: string` in code is a type, not a token. */ | |
| 124 | + | const TYPES = new Set(["string", "String", "str", "&str", "number", "bool", "boolean", "null", "None", "undefined", "true", "false"]); | |
| 125 | + | ||
| 126 | + | const PREFIXED = | |
| 127 | + | /\b(g1t|ghp|gho|ghu|ghs|ghr|github_pat|glpat|gloas|glrt|glptt|gldt|xox[abeprs]|sk-ant|sk-proj|sk-svcacct|sk-admin|sk|rk|whsec|npm|hf|pypi|dop_v1|shpat|shpss|sntrys|lin_api|figd)([-_])([A-Za-z0-9_-]{12,})/g; | |
| 128 | + | const ASSIGNED = | |
| 129 | + | /([A-Za-z0-9_.-]*(?:token|secret|passw(?:or)?d|pwd|api[_-]?key|apikey|private[_-]?key|access[_-]?key|auth[_-]?key|signing[_-]?key|session[_-]?key|credentials?|webhook[_-]?url|dsn|connection[_-]?string)["']?\s*(?:=|:)\s*)("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|[^\s,;&)}\]]+)/gi; | |
| 130 | + | const QUERY = | |
| 131 | + | /([?&](?:access_token|refresh_token|id_token|token|key|api_key|apikey|secret|client_secret|sig|signature|password|code|x-amz-signature|x-amz-credential|x-amz-security-token)=)[^&\s#"'`]+/gi; | |
| 132 | + | const FLAG = /(--(?:token|password|passwd|secret|api-key|apikey|access-token|auth-token|client-secret|private-key)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; | |
| 133 | + | ||
| 134 | + | /** | |
| 135 | + | * Hides what looks like a credential before it is sent: in a command | |
| 136 | + | * line, a prompt, a title, or what a subagent was asked and reported. | |
| 137 | + | * g1t hides them again as it records and shows them; this keeps them from | |
| 138 | + | * leaving the machine at all. | |
| 139 | + | */ | |
| 112 | 140 | export function redact(text) { | |
| 141 | + | const keep = (whole, head, value) => (reference(value) ? whole : `${head}${REDACTED}`); | |
| 113 | 142 | return String(text) | |
| 114 | − | .replace(/\b(g1t|ghp|gho|ghs|github_pat|glpat|xox[abpr]|sk-ant|sk|rk)[-_][A-Za-z0-9_-]{8,}/g, "$1_…") | |
| 115 | − | .replace(/(authorization:\s*(?:bearer|basic|token)\s+)[^\s"']+/gi, "$1…") | |
| 116 | − | .replace(/(\b[A-Za-z_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_KEY)[A-Za-z_]*=)("[^"]*"|'[^']*'|\S+)/gi, "$1…") | |
| 117 | − | .replace(/(--(?:token|password|secret)[= ])\S+/gi, "$1…") | |
| 118 | − | .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/g, "$1…@"); | |
| 143 | + | .replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g, REDACTED) | |
| 144 | + | .replace(/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, REDACTED) | |
| 145 | + | .replace(PREFIXED, (whole, prefix, sep, body) => (/[A-Za-z]/.test(body) && /\d/.test(body) ? `${prefix}${sep}${REDACTED}` : whole)) | |
| 146 | + | .replace(/\b(?:(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{35}|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,})\b/g, REDACTED) | |
| 147 | + | .replace(/\b((?:proxy-)?authorization\s*[:=]\s*(?:(?:bearer|basic|token|bot|digest)\s+)?)([^\s"'`,;]+)/gi, keep) | |
| 148 | + | .replace(/\b(bearer\s+)([A-Za-z0-9._~+/=-]{8,})/gi, keep) | |
| 149 | + | .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, `$1${REDACTED}@`) | |
| 150 | + | .replace(QUERY, `$1${REDACTED}`) | |
| 151 | + | .replace(FLAG, keep) | |
| 152 | + | .replace(/((?:^|\s)(?:-u|--user)\s+[^\s:]+:)\S+/g, `$1${REDACTED}`) | |
| 153 | + | .replace(ASSIGNED, (whole, head, value) => (TYPES.has(value) ? whole : keep(whole, head, value))); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | /** Text a person or an agent wrote, as it is sent: credentials out, cut to `limit`. */ | |
| 157 | + | export function scrub(text, limit) { | |
| 158 | + | return clip(redact(text), limit); | |
| 119 | 159 | } | |
| 120 | 160 | ||
| 121 | 161 | /** A path as the repository names it: relative to its root, with forward slashes; null outside it. */ | |
| ⋯ | |||
| 319 | 359 | // A turn Claude Code starts itself for a background task carries that | |
| 320 | 360 | // task's output, which is never sent: it is not a prompt of yours. | |
| 321 | 361 | if (!prompt.trim() || /^\s*<task-notification>/.test(prompt)) return null; | |
| 322 | − | if (env.G1T_RECORD_PROMPTS !== "0") job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", clip(prompt, PROMPT_LIMIT)] }); | |
| 362 | + | if (env.G1T_RECORD_PROMPTS !== "0") job.steps.push({ args: ["session", "log", "--kind", "prompt", "--", scrub(prompt, PROMPT_LIMIT)] }); | |
| 323 | 363 | // A skill typed as /plugin:name is a skill used (its name), whether or not prompts are sent. | |
| 324 | 364 | const skill = typedSkill(prompt); | |
| 325 | 365 | if (skill) job.steps.push(skillStep(skill)); | |
| 326 | 366 | // What it is for titles the session, until it has a title (see hook and work). | |
| 327 | − | const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(prompt); | |
| 367 | + | const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(redact(prompt)); | |
| 328 | 368 | if (intent) job.steps.push({ title: intent }); | |
| 329 | 369 | return job.steps.length ? job : null; | |
| 330 | 370 | } | |
| ⋯ | |||
| 337 | 377 | const args = ["session", "end", "--reason", "done"]; | |
| 338 | 378 | const result = typeof event.last_assistant_message === "string" ? event.last_assistant_message.trim() : ""; | |
| 339 | 379 | // --flag=VALUE throughout for text: one that starts with a dash ("- Found …") is still a value. | |
| 340 | − | if (result) args.push(`--result=${clip(result, RESULT_LIMIT)}`); | |
| 380 | + | if (result) args.push(`--result=${scrub(result, RESULT_LIMIT)}`); | |
| 341 | 381 | job.steps.push({ args }); | |
| 342 | 382 | return job; | |
| 343 | 383 | } | |
| ⋯ | |||
| 365 | 405 | job.subagent = { | |
| 366 | 406 | id: String(agentId), | |
| 367 | 407 | type: input.subagent_type || null, | |
| 368 | − | task: input.description ? clip(input.description, TASK_LIMIT) : null, | |
| 369 | − | prompt: env.G1T_RECORD_PROMPTS === "0" || !input.prompt ? null : clip(input.prompt, AGENT_PROMPT_LIMIT), | |
| 408 | + | task: input.description ? scrub(input.description, TASK_LIMIT) : null, | |
| 409 | + | prompt: env.G1T_RECORD_PROMPTS === "0" || !input.prompt ? null : scrub(input.prompt, AGENT_PROMPT_LIMIT), | |
| 370 | 410 | model: response.resolvedModel || null, | |
| 371 | − | result: response.status === "completed" ? clip(agentResult(response), RESULT_LIMIT) || null : null, | |
| 411 | + | result: response.status === "completed" ? scrub(agentResult(response), RESULT_LIMIT) || null : null, | |
| 372 | 412 | }; | |
| 373 | 413 | // Its task, kept for its subagent, is no other subagent's (see work). | |
| 374 | 414 | if (event.tool_use_id) job.toolUseId = String(event.tool_use_id); | |
| ⋯ | |||
| 606 | 646 | write(files.task(toolUseId || `${now}-${randomUUID()}`), JSON.stringify({ | |
| 607 | 647 | tool_use_id: toolUseId || null, | |
| 608 | 648 | type: input?.subagent_type || null, | |
| 609 | − | task: input?.description ? clip(input.description, TASK_LIMIT) : null, | |
| 610 | − | prompt: input?.prompt ? clip(input.prompt, AGENT_PROMPT_LIMIT) : null, | |
| 649 | + | task: input?.description ? scrub(input.description, TASK_LIMIT) : null, | |
| 650 | + | prompt: input?.prompt ? scrub(input.prompt, AGENT_PROMPT_LIMIT) : null, | |
| 611 | 651 | key: input?.prompt ? promptKey(input.prompt) : null, | |
| 612 | 652 | at: now, | |
| 613 | 653 | })); | |
| ⋯ | |||
| 715 | 755 | evidence = subagentEvidence(paths); | |
| 716 | 756 | } | |
| 717 | 757 | const task = takeTask(files, { type: agent.type, toolUseId: evidence.toolUseId, prompt: evidence.prompt }); | |
| 718 | − | const what = task?.task ?? (evidence.description ? clip(evidence.description, TASK_LIMIT) : null); | |
| 719 | − | const prompt = task?.prompt ?? (evidence.prompt ? clip(evidence.prompt, AGENT_PROMPT_LIMIT) : null); | |
| 758 | + | const what = task?.task ?? (evidence.description ? scrub(evidence.description, TASK_LIMIT) : null); | |
| 759 | + | const prompt = task?.prompt ?? (evidence.prompt ? scrub(evidence.prompt, AGENT_PROMPT_LIMIT) : null); | |
| 720 | 760 | if (what) agent.task = what; | |
| 721 | 761 | if (prompt && env.G1T_RECORD_PROMPTS !== "0") agent.prompt = prompt; | |
| 722 | 762 | return agent; | |
| ⋯ | |||
| 1121 | 1161 | const kept = readJson(files.unrecorded, {}) ?? {}; | |
| 1122 | 1162 | const output = sessionStart(event, env, findCli(env)); | |
| 1123 | 1163 | if (!output) { | |
| 1124 | − | const intent = name === "UserPromptSubmit" && !kept.title && env.G1T_RECORD_PROMPTS !== "0" ? intentTitle(event.prompt) : null; | |
| 1164 | + | const intent = name === "UserPromptSubmit" && !kept.title && env.G1T_RECORD_PROMPTS !== "0" ? intentTitle(redact(event.prompt)) : null; | |
| 1125 | 1165 | if (intent) write(files.unrecorded, JSON.stringify({ ...kept, title: intent })); | |
| 1126 | 1166 | return null; | |
| 1127 | 1167 | } | |
| 315 | 315 | assert.ok(!line.includes("--token abc")); | |
| 316 | 316 | }); | |
| 317 | 317 | ||
| 318 | + | test("hide credentials wherever text is sent, and leave references alone", () => { | |
| 319 | + | for (const [text, secret] of [ | |
| 320 | + | ["$env:G1T_TOKEN='abc123def456'; g1t whoami", "abc123def456"], | |
| 321 | + | ["G1T_TOKEN=abc123def456 g1t whoami", "abc123def456"], | |
| 322 | + | ['{"password": "hunter2!"}', "hunter2"], | |
| 323 | + | ["curl -u admin:pa55word https://x", "pa55word"], | |
| 324 | + | ["open https://x.com/cb?state=1&token=t0k3n", "t0k3n"], | |
| 325 | + | ["jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N", "dozjgNryP4J3"], | |
| 326 | + | ["-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5v\n-----END OPENSSH PRIVATE KEY-----", "b3BlbnNzaC1"], | |
| 327 | + | ["here is my key: sk-ant-api03-abcDEF123456ghiJKL, use it", "abcDEF123456ghiJKL"], | |
| 328 | + | ]) { | |
| 329 | + | const out = redact(text); | |
| 330 | + | assert.ok(!out.includes(secret), `${secret} in ${out}`); | |
| 331 | + | assert.ok(out.includes("[redacted]"), out); | |
| 332 | + | } | |
| 333 | + | for (const kept of ["TOKEN=$G1T_TOKEN", "token: ${{ secrets.DEPLOY }}", "fn f(token: String)", "npm_config_user_agent", "git show 58def4040e93a1dcda5f388f55360f3cd46cd97e"]) { | |
| 334 | + | assert.equal(redact(kept), kept); | |
| 335 | + | } | |
| 336 | + | }); | |
| 337 | + | ||
| 318 | 338 | test("find the issue in a branch name", () => { | |
| 319 | 339 | assert.equal(issueFromBranch("42-fix-login"), 42); | |
| 320 | 340 | assert.equal(issueFromBranch("issue-7"), 7); |
| 89 | 89 | ]; | |
| 90 | 90 | ||
| 91 | 91 | /// The methods this module answers. | |
| 92 | − | pub const METHODS: [&str; 5] = ["start_agent_session", "agent_sessions_setup", "sync_agent_session", "stop_agent_session", "sync_agent_sessions"]; | |
| 92 | + | pub const METHODS: [&str; 6] = ["start_agent_session", "agent_sessions_setup", "sync_agent_session", "agent_session_link", "stop_agent_session", "sync_agent_sessions"]; | |
| 93 | 93 | ||
| 94 | 94 | // --- Requests, built without calling anything ------------------------------ | |
| 95 | 95 | ||
| ⋯ | |||
| 1470 | 1470 | Ok(Outcome::Ok(self.sync_run(run).await?)) | |
| 1471 | 1471 | } | |
| 1472 | 1472 | ||
| 1473 | + | /// `agent_session_link`: the session on the Claude Console, for | |
| 1474 | + | /// whoever started it and no one else. Anyone else, or another | |
| 1475 | + | /// repository, is told there is no such session, not that it is theirs. | |
| 1476 | + | pub(crate) async fn agent_session_link(&self, a: AgentSessionLinkArgs) -> Result<Outcome<String>> { | |
| 1477 | + | let missing = || Outcome::fail(FailureCode::NotFound, "No session g1t started has that id."); | |
| 1478 | + | let Some(run) = self.run_row(a.id.trim()).await? else { | |
| 1479 | + | return Ok(missing()); | |
| 1480 | + | }; | |
| 1481 | + | let repo = format!("{}/{}", a.repo.namespace, a.repo.name).to_lowercase(); | |
| 1482 | + | if run.repo.to_lowercase() != repo || run.actor().id != a.actor.id || a.actor.id.is_empty() { | |
| 1483 | + | return Ok(missing()); | |
| 1484 | + | } | |
| 1485 | + | let console_workspace = self.ai_row(&run.connection_id).await?.and_then(|row| row.config().console_workspace); | |
| 1486 | + | Ok(Outcome::Ok(console_url(console_workspace.as_deref(), &run.provider_session_id))) | |
| 1487 | + | } | |
| 1488 | + | ||
| 1473 | 1489 | /// `stop_agent_session`: interrupts the session and ends its record. | |
| 1474 | 1490 | pub(crate) async fn stop_agent_session(&self, a: StopAgentSessionArgs) -> Result<Outcome<bool>> { | |
| 1475 | 1491 | let Some(run) = self.run_row(a.id.trim()).await? else { | |
| ⋯ | |||
| 1544 | 1560 | "start_agent_session" => reply(&service.start_agent_session(args(body)?).await?), | |
| 1545 | 1561 | "agent_sessions_setup" => reply(&service.agent_sessions_setup(args(body)?).await?), | |
| 1546 | 1562 | "sync_agent_session" => reply(&service.sync_agent_session(args(body)?).await?), | |
| 1563 | + | "agent_session_link" => reply(&service.agent_session_link(args(body)?).await?), | |
| 1547 | 1564 | "stop_agent_session" => reply(&service.stop_agent_session(args(body)?).await?), | |
| 1548 | 1565 | "sync_agent_sessions" => reply(&service.sync_running().await?), | |
| 1549 | 1566 | _ => worker::Response::error("Unknown method", 404), | |
| 12 | 12 | g1t-contracts.workspace = true | |
| 13 | 13 | g1t-kit.workspace = true | |
| 14 | 14 | g1t-rules.workspace = true | |
| 15 | + | g1t-scan.workspace = true | |
| 15 | 16 | serde.workspace = true | |
| 16 | 17 | serde_json.workspace = true | |
| 17 | 18 | worker.workspace = true |
| 16 | 16 | mod links; | |
| 17 | 17 | mod overlaps; | |
| 18 | 18 | mod sessions; | |
| 19 | + | pub(crate) mod shown; | |
| 19 | 20 | #[cfg(test)] | |
| 20 | 21 | mod tests; | |
| 21 | 22 |
| 30 | 30 | /// The most pull requests one `pull_sessions` asks about. | |
| 31 | 31 | const MAX_PULL_SESSIONS: usize = 100; | |
| 32 | 32 | use super::updates::push_delivery; | |
| 33 | − | use super::{SESSION_COLUMNS, SessionRow, after_minutes, tidy, unwrap_or_fail}; | |
| 33 | + | use super::{SESSION_COLUMNS, SessionRow, after_minutes, shown, tidy, unwrap_or_fail}; | |
| 34 | 34 | use crate::Work; | |
| 35 | 35 | use crate::rows::NumberRow; | |
| 36 | 36 | ||
| ⋯ | |||
| 268 | 268 | pull, | |
| 269 | 269 | branch: tidy(a.branch.as_deref(), MAX_BRANCH_CHARS), | |
| 270 | 270 | head_commit: tidy(a.head_commit.as_deref(), MAX_COMMIT_CHARS), | |
| 271 | − | title: tidy(a.title.as_deref(), MAX_TITLE_CHARS), | |
| 271 | + | title: tidy(a.title.as_deref().map(shown::clean).as_deref(), MAX_TITLE_CHARS), | |
| 272 | 272 | agent_type: tidy(a.agent_type.as_deref(), MAX_AGENT_CHARS), | |
| 273 | − | task: clip(a.task.as_deref(), MAX_TASK_CHARS), | |
| 274 | − | prompt: clip(a.prompt.as_deref(), MAX_PROMPT_CHARS), | |
| 273 | + | task: clip(a.task.as_deref().map(shown::clean).as_deref(), MAX_TASK_CHARS), | |
| 274 | + | prompt: clip(a.prompt.as_deref().map(shown::clean).as_deref(), MAX_PROMPT_CHARS), | |
| 275 | 275 | model: tidy(a.model.as_deref(), MAX_MODEL_CHARS), | |
| 276 | 276 | result: None, | |
| 277 | 277 | }; | |
| ⋯ | |||
| 534 | 534 | pull, | |
| 535 | 535 | branch: tidy(a.branch.as_deref(), MAX_BRANCH_CHARS), | |
| 536 | 536 | head_commit: tidy(a.head_commit.as_deref(), MAX_COMMIT_CHARS), | |
| 537 | − | title: tidy(a.title.as_deref(), MAX_TITLE_CHARS), | |
| 537 | + | title: tidy(a.title.as_deref().map(shown::clean).as_deref(), MAX_TITLE_CHARS), | |
| 538 | 538 | agent_type: tidy(a.agent_type.as_deref(), MAX_AGENT_CHARS), | |
| 539 | − | task: clip(a.task.as_deref(), MAX_TASK_CHARS), | |
| 540 | − | prompt: clip(a.prompt.as_deref(), MAX_PROMPT_CHARS), | |
| 539 | + | task: clip(a.task.as_deref().map(shown::clean).as_deref(), MAX_TASK_CHARS), | |
| 540 | + | prompt: clip(a.prompt.as_deref().map(shown::clean).as_deref(), MAX_PROMPT_CHARS), | |
| 541 | 541 | model: tidy(a.model.as_deref(), MAX_MODEL_CHARS), | |
| 542 | − | result: clip(a.result.as_deref(), MAX_RESULT_CHARS), | |
| 542 | + | result: clip(a.result.as_deref().map(shown::clean).as_deref(), MAX_RESULT_CHARS), | |
| 543 | 543 | }; | |
| 544 | 544 | self.resume(&repo, row, &a.actor, fields).await | |
| 545 | 545 | } | |
| ⋯ | |||
| 706 | 706 | ids.extend(children.iter().map(|child| child.id.clone())); | |
| 707 | 707 | let (touched, links, skills) = self.tree_facts(&repo.id, &ids).await?; | |
| 708 | 708 | let updates = self.pending_updates(&session.id).await?; | |
| 709 | − | Ok(Outcome::Ok(SessionDetail { session, entries, children, touched, links, skills, root, updates })) | |
| 709 | + | let detail = SessionDetail { session, entries, children, touched, links, skills, root, updates, limited: false }; | |
| 710 | + | Ok(Outcome::Ok(shown::detail(detail, &a.viewer, &repo.namespace))) | |
| 710 | 711 | } | |
| 711 | 712 | ||
| 712 | 713 | /// The sessions tied to some pull requests, for Home (`PullSessionsArgs`). | |
| ⋯ | |||
| 812 | 813 | self.fill_subagents(&mut sessions).await?; | |
| 813 | 814 | } | |
| 814 | 815 | self.fill_first_prompts(&mut sessions).await?; | |
| 816 | + | shown::sessions(&mut sessions, &a.viewer, &repo.namespace); | |
| 815 | 817 | Ok(Outcome::Ok(sessions)) | |
| 816 | 818 | } | |
| 817 | 819 | ||
| ⋯ | |||
| 896 | 898 | for keyed in a.entries { | |
| 897 | 899 | let entry = keyed.entry; | |
| 898 | 900 | let kind = serde_json::to_value(entry.kind)?; | |
| 899 | − | let mut text: String = entry.text.chars().take(crate::MAX_ENTRY_CHARS).collect(); | |
| 901 | + | // Kept without credentials: a session's page may show it to a | |
| 902 | + | // whole team, and its prompts to its author on any device. | |
| 903 | + | let mut text: String = shown::clean(&entry.text.chars().take(crate::MAX_ENTRY_CHARS).collect::<String>()); | |
| 900 | 904 | if entry.kind == SessionEntryKind::Skill { | |
| 901 | 905 | // A skill is its name, nothing more. | |
| 902 | 906 | text = text.trim().chars().take(MAX_AGENT_CHARS).collect(); | |
| 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 | + | } |
| 2771 | 2771 | let mut statements = Vec::with_capacity(a.entries.len() + 1); | |
| 2772 | 2772 | for entry in a.entries { | |
| 2773 | 2773 | let kind = serde_json::to_value(entry.kind)?; | |
| 2774 | − | let text: String = entry.text.chars().take(MAX_ENTRY_CHARS).collect(); | |
| 2774 | + | let text = coordination::shown::clean(&entry.text.chars().take(MAX_ENTRY_CHARS).collect::<String>()); | |
| 2775 | 2775 | // Each insert takes the next sequence number itself, so two | |
| 2776 | 2776 | // writers appending at once cannot collide. | |
| 2777 | 2777 | statements.push( |
| 396 | 396 | pub tool: Option<String>, | |
| 397 | 397 | pub commit: Option<String>, | |
| 398 | 398 | pub at: String, | |
| 399 | + | /// Whose harness session recorded it, where the query says. | |
| 400 | + | #[serde(default)] | |
| 401 | + | pub actor_id: Option<String>, | |
| 399 | 402 | } | |
| 400 | 403 | ||
| 401 | 404 | impl From<SessionRow> for SessionEntry { | |
| ⋯ | |||
| 407 | 410 | tool: row.tool, | |
| 408 | 411 | commit: row.commit, | |
| 409 | 412 | at: row.at, | |
| 413 | + | withheld: false, | |
| 410 | 414 | } | |
| 411 | 415 | } | |
| 412 | 416 | } | |
| 13 | 13 | use g1t_contracts::agents::*; | |
| 14 | 14 | use g1t_contracts::repos::{Repo, RepoPath}; | |
| 15 | 15 | use g1t_contracts::time::rfc3339; | |
| 16 | − | use g1t_contracts::work::{PullStatus, SessionEntry}; | |
| 16 | + | use g1t_contracts::work::{PullStatus, SessionEntry, SessionEntryKind}; | |
| 17 | + | use g1t_scan::redact::soften; | |
| 17 | 18 | use g1t_contracts::{FailureCode, Membership, Outcome, User, Viewer, new_id}; | |
| 18 | 19 | use g1t_kit::now_ms; | |
| 19 | 20 | use serde::Deserialize; | |
| ⋯ | |||
| 21 | 22 | use worker::wasm_bindgen::JsValue; | |
| 22 | 23 | ||
| 23 | 24 | use crate::checks::{hash, new_token}; | |
| 25 | + | use crate::coordination::shown::{self, Sight}; | |
| 24 | 26 | use crate::rows::SessionRow; | |
| 25 | 27 | use crate::{Work, optional}; | |
| 26 | 28 | ||
| ⋯ | |||
| 531 | 533 | agent: row.agent, | |
| 532 | 534 | entries: row.entries, | |
| 533 | 535 | tools: row.tools, | |
| 534 | − | prompt: row.prompt.map(|prompt| one_line(&prompt, 200)), | |
| 536 | + | // Someone's words to their agent: not for outsiders, | |
| 537 | + | // and to members without credentials or swearing. | |
| 538 | + | prompt: row.prompt.filter(|_| member).map(|prompt| soften(&one_line(&prompt, 200))).filter(|prompt| !prompt.is_empty()), | |
| 535 | 539 | runs: mine.len() as u32, | |
| 536 | 540 | cost_usd: (member && mine.iter().any(|run| run.cost_usd.is_some())).then_some(spent), | |
| 537 | 541 | active: mine | |
| ⋯ | |||
| 552 | 556 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 553 | 557 | }; | |
| 554 | 558 | let member = is_member(&a.viewer, &repo.namespace); | |
| 559 | + | // Each entry as the viewer may see it: a prompt or a tool's output | |
| 560 | + | // only by whoever's session recorded it, nothing to an outsider | |
| 561 | + | // but g1t's own notes (coordination/shown.rs). | |
| 555 | 562 | let entries: Vec<SessionEntry> = self | |
| 556 | 563 | .db | |
| 557 | 564 | .prepare( | |
| 558 | − | "SELECT seq, kind, text, tool, \"commit\", at FROM session_entries | |
| 559 | − | WHERE pull_id = ? ORDER BY seq LIMIT ?", | |
| 565 | + | "SELECT e.seq, e.kind, e.text, e.tool, e.\"commit\", e.at, s.actor_id FROM session_entries e | |
| 566 | + | LEFT JOIN sessions s ON s.id = e.session_id | |
| 567 | + | WHERE e.pull_id = ? ORDER BY e.seq LIMIT ?", | |
| 560 | 568 | ) | |
| 561 | 569 | .bind(&[pull.id.as_str().into(), SESSION_ENTRIES.into()])? | |
| 562 | 570 | .all() | |
| 563 | 571 | .await? | |
| 564 | 572 | .results::<SessionRow>()? | |
| 565 | 573 | .into_iter() | |
| 566 | − | .map(SessionEntry::from) | |
| 574 | + | .filter_map(|row| { | |
| 575 | + | let note = row.kind == SessionEntryKind::Note && row.actor_id.is_none(); | |
| 576 | + | let sight = match &row.actor_id { | |
| 577 | + | Some(actor) => Sight::of(&a.viewer, &repo.namespace, actor), | |
| 578 | + | None if member => Sight::Member, | |
| 579 | + | None => Sight::Outside, | |
| 580 | + | }; | |
| 581 | + | let entry = SessionEntry::from(row); | |
| 582 | + | if note && sight == Sight::Outside { | |
| 583 | + | return Some(SessionEntry { text: shown::clean(&entry.text), ..entry }); | |
| 584 | + | } | |
| 585 | + | shown::entry(entry, sight) | |
| 586 | + | }) | |
| 567 | 587 | .collect(); | |
| 568 | 588 | let runs: Vec<AgentRun> = self | |
| 569 | 589 | .db | |
Comments
Sign in to comment.