| 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))); |
| 119 | 154 | | } |
| 120 | 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); |
| 159 | + | } |
| 160 | + | |
| 121 | 161 | | /** A path as the repository names it: relative to its root, with forward slashes; null outside it. */ |
| 122 | | − | export function repoPath(path, cwd) { |
| 162 | + | export function repoPath(path, cwd, root = repoRoot(cwd)) { |
| 123 | 163 | | if (typeof path !== "string" || !path) return null; |
| 124 | | − | if (!isAbsolute(path)) return path.replaceAll("\\", "/").replace(/^\.\//, ""); |
| 125 | | − | if (!cwd) return null; |
| 126 | | − | const rel = relative(cwd, path); |
| 164 | + | // From the working copy's top, wherever the shell has moved to since |
| 165 | + | // (`cd apps/web` leaves Claude Code's cwd there); from cwd outside one. |
| 166 | + | const base = root || cwd; |
| 167 | + | if (!isAbsolute(path)) { |
| 168 | + | if (!cwd || !root) return path.replaceAll("\\", "/").replace(/^\.\//, ""); |
| 169 | + | path = resolve(cwd, path); |
| 170 | + | } |
| 171 | + | if (!base) return null; |
| 172 | + | const rel = relative(base, path); |
| 127 | 173 | | if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null; |
| 128 | 174 | | return rel.replaceAll("\\", "/"); |
| 129 | 175 | | } |
| ⋯ |
| 218 | 264 | | /** A turn the harness starts itself, as a tag: `<command-name>`, `<task-notification>`, one with attributes. */ |
| 219 | 265 | | const HARNESS_TAG = /^\s*<[a-z][\w-]*(?:\s[^>]*)?>/i; |
| 220 | 266 | | |
| 267 | + | /** What an editor adds ahead of a prompt, a whole block at a time: `<ide_opened_file>…</ide_opened_file>`. */ |
| 268 | + | const IDE_CONTEXT = /^\s*<(ide_[a-z_]+|system-reminder)\b[^>]*>[\s\S]*?<\/\1>/i; |
| 269 | + | |
| 221 | 270 | | /** |
| 222 | 271 | | * What a prompt is for, as a session's title: its first line or sentence, |
| 223 | 272 | | * without code blocks or markdown, whitespace collapsed, clipped at |
| ⋯ |
| 234 | 283 | | text = text |
| 235 | 284 | | .replace(/<pasted_content\b[^>]*>[\s\S]*?(?:<\/pasted_content[^>]*>|$)/gi, "\n") |
| 236 | 285 | | .replace(/\[Image(?: #\d+)?(?::[^\]\n]*)?\]/g, " "); |
| 286 | + | // Context an editor puts before what was typed (VS Code's |
| 287 | + | // <ide_opened_file>, <ide_selection>) is not a turn the harness started. |
| 288 | + | while (IDE_CONTEXT.test(text)) text = text.replace(IDE_CONTEXT, ""); |
| 237 | 289 | | if (/^\s*\//.test(text) || HARNESS_TAG.test(text)) return null; |
| 238 | 290 | | text = text |
| 239 | 291 | | .replace(/```[\s\S]*?(```|$)/g, "\n") |
| ⋯ |
| 307 | 359 | | // A turn Claude Code starts itself for a background task carries that |
| 308 | 360 | | // task's output, which is never sent: it is not a prompt of yours. |
| 309 | 361 | | if (!prompt.trim() || /^\s*<task-notification>/.test(prompt)) return null; |
| 310 | | − | 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)] }); |
| 311 | 363 | | // A skill typed as /plugin:name is a skill used (its name), whether or not prompts are sent. |
| 312 | 364 | | const skill = typedSkill(prompt); |
| 313 | 365 | | if (skill) job.steps.push(skillStep(skill)); |
| 314 | 366 | | // What it is for titles the session, until it has a title (see hook and work). |
| 315 | | − | const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(prompt); |
| 367 | + | const intent = env.G1T_RECORD_PROMPTS === "0" ? null : intentTitle(redact(prompt)); |
| 316 | 368 | | if (intent) job.steps.push({ title: intent }); |
| 317 | 369 | | return job.steps.length ? job : null; |
| 318 | 370 | | } |
| ⋯ |
| 325 | 377 | | const args = ["session", "end", "--reason", "done"]; |
| 326 | 378 | | const result = typeof event.last_assistant_message === "string" ? event.last_assistant_message.trim() : ""; |
| 327 | 379 | | // --flag=VALUE throughout for text: one that starts with a dash ("- Found …") is still a value. |
| 328 | | − | if (result) args.push(`--result=${clip(result, RESULT_LIMIT)}`); |
| 380 | + | if (result) args.push(`--result=${scrub(result, RESULT_LIMIT)}`); |
| 329 | 381 | | job.steps.push({ args }); |
| 330 | 382 | | return job; |
| 331 | 383 | | } |
| ⋯ |
| 353 | 405 | | job.subagent = { |
| 354 | 406 | | id: String(agentId), |
| 355 | 407 | | type: input.subagent_type || null, |
| 356 | | − | task: input.description ? clip(input.description, TASK_LIMIT) : null, |
| 357 | | − | 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), |
| 358 | 410 | | model: response.resolvedModel || null, |
| 359 | | − | result: response.status === "completed" ? clip(agentResult(response), RESULT_LIMIT) || null : null, |
| 411 | + | result: response.status === "completed" ? scrub(agentResult(response), RESULT_LIMIT) || null : null, |
| 360 | 412 | | }; |
| 361 | 413 | | // Its task, kept for its subagent, is no other subagent's (see work). |
| 362 | 414 | | if (event.tool_use_id) job.toolUseId = String(event.tool_use_id); |
| 363 | 415 | | } |
| 364 | 416 | | } |
| 365 | 417 | | if (SHELL_TOOLS.has(tool)) { |
| 418 | + | // What the command wrote (sed, a script, a formatter): the files it |
| 419 | + | // changed, against what PreToolUse saw before it ran. |
| 420 | + | if (event.session_id && event.tool_use_id) { |
| 421 | + | const kept = sessionFiles(event.session_id, env).shell(event.tool_use_id); |
| 422 | + | const before = readJson(kept, null); |
| 423 | + | rmSync(kept, { force: true }); |
| 424 | + | for (const file of shellChanges(before, before ? dirtyFiles(cwd) : null).slice(0, 100)) touch.push(`--file=${file}`); |
| 425 | + | } |
| 366 | 426 | | const issue = issueFromCommand(input.command); |
| 367 | 427 | | if (issue) { |
| 368 | 428 | | job.issue = issue; |
| ⋯ |
| 406 | 466 | | return out.status === 0 ? out.stdout.trim() : null; |
| 407 | 467 | | } |
| 408 | 468 | | |
| 469 | + | /** The most uncommitted files compared around one shell command. */ |
| 470 | + | const MAX_DIRTY = 2000; |
| 471 | + | |
| 472 | + | /** |
| 473 | + | * The working copy's uncommitted files (modified, added, deleted, renamed |
| 474 | + | * by their new name, untracked; never ignored), each with what it is now: |
| 475 | + | * its modified time and size, or "gone". Paths from the top of the working |
| 476 | + | * copy. Null outside one. |
| 477 | + | */ |
| 478 | + | export function dirtyFiles(cwd) { |
| 479 | + | const root = repoRoot(cwd); |
| 480 | + | if (!root) return null; |
| 481 | + | const out = spawnSync("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, encoding: "utf8", windowsHide: true, timeout: 3000 }); |
| 482 | + | if (out.status !== 0) return null; |
| 483 | + | const files = {}; |
| 484 | + | const records = out.stdout.split("\0"); |
| 485 | + | let count = 0; |
| 486 | + | for (let i = 0; i < records.length && count < MAX_DIRTY; i++) { |
| 487 | + | const record = records[i]; |
| 488 | + | if (record.length < 4) continue; |
| 489 | + | // A rename or copy names its new path, then its old one. |
| 490 | + | if (record[0] === "R" || record[0] === "C") i++; |
| 491 | + | const path = record.slice(3); |
| 492 | + | try { |
| 493 | + | const stat = statSync(join(root, path)); |
| 494 | + | files[path] = `${stat.mtimeMs}:${stat.size}`; |
| 495 | + | } catch { |
| 496 | + | files[path] = "gone"; |
| 497 | + | } |
| 498 | + | count++; |
| 499 | + | } |
| 500 | + | return files; |
| 501 | + | } |
| 502 | + | |
| 503 | + | /** |
| 504 | + | * What a shell command wrote, from the uncommitted files before it and |
| 505 | + | * after: each one new since, or changed since. One it committed or put |
| 506 | + | * back is not a write. Others writing in the same working copy during the |
| 507 | + | * command are counted too, but only then, never what they wrote before. |
| 508 | + | */ |
| 509 | + | export function shellChanges(before, after) { |
| 510 | + | if (!before || !after) return []; |
| 511 | + | return Object.keys(after).filter((path) => before[path] !== after[path]); |
| 512 | + | } |
| 513 | + | |
| 514 | + | const roots = new Map(); |
| 515 | + | /** The top of the working copy `cwd` is in, asked once per directory; null outside one. */ |
| 516 | + | function repoRoot(cwd) { |
| 517 | + | if (!cwd) return null; |
| 518 | + | if (!roots.has(cwd)) roots.set(cwd, git(cwd, ["rev-parse", "--show-toplevel"]) || null); |
| 519 | + | return roots.get(cwd); |
| 520 | + | } |
| 521 | + | |
| 409 | 522 | | /** The g1t CLI on PATH, without running it. G1T_BIN names another. */ |
| 410 | 523 | | export function findCli(env = process.env) { |
| 411 | 524 | | if (env.G1T_BIN) return existsSync(env.G1T_BIN) ? env.G1T_BIN : null; |
| ⋯ |
| 429 | 542 | | encoding: "utf8", |
| 430 | 543 | | windowsHide: true, |
| 431 | 544 | | ...options, |
| 545 | + | // The hooks say what this session wrote (see shellChanges); the CLI's |
| 546 | + | // own guess, every uncommitted file in the working copy, would claim |
| 547 | + | // what other sessions sharing it wrote too. |
| 548 | + | env: { ...(options?.env ?? process.env), G1T_TOUCH: "0" }, |
| 432 | 549 | | }); |
| 433 | 550 | | } |
| 434 | 551 | | |
| ⋯ |
| 482 | 599 | | turn: join(dir, `${safe}.turn`), |
| 483 | 600 | | titled: join(dir, `${safe}.titled`), |
| 484 | 601 | | task: (key) => join(dir, `${safe}.task.${safeName(key)}.json`), |
| 602 | + | shell: (toolUseId) => join(dir, `${safe}.shell.${safeName(toolUseId)}.json`), |
| 485 | 603 | | unrecorded: join(dir, `${safe}.unrecorded.json`), |
| 486 | 604 | | agent: (agentId) => join(dir, `${safe}.agent.${safeName(String(agentId).replace(/^agent-/, ""))}.id`), |
| 487 | 605 | | }; |
| ⋯ |
| 528 | 646 | | write(files.task(toolUseId || `${now}-${randomUUID()}`), JSON.stringify({ |
| 529 | 647 | | tool_use_id: toolUseId || null, |
| 530 | 648 | | type: input?.subagent_type || null, |
| 531 | | − | task: input?.description ? clip(input.description, TASK_LIMIT) : null, |
| 532 | | − | 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, |
| 533 | 651 | | key: input?.prompt ? promptKey(input.prompt) : null, |
| 534 | 652 | | at: now, |
| 535 | 653 | | })); |
| ⋯ |
| 637 | 755 | | evidence = subagentEvidence(paths); |
| 638 | 756 | | } |
| 639 | 757 | | const task = takeTask(files, { type: agent.type, toolUseId: evidence.toolUseId, prompt: evidence.prompt }); |
| 640 | | − | const what = task?.task ?? (evidence.description ? clip(evidence.description, TASK_LIMIT) : null); |
| 641 | | − | 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); |
| 642 | 760 | | if (what) agent.task = what; |
| 643 | 761 | | if (prompt && env.G1T_RECORD_PROMPTS !== "0") agent.prompt = prompt; |
| 644 | 762 | | return agent; |
| ⋯ |
| 810 | 928 | | // command, so a command before this (rare: it is the first second) |
| 811 | 929 | | // uses the working copy's remembered session. |
| 812 | 930 | | try { |
| 813 | | − | writeFileSync(job.envFile, `export G1T_SESSION=${id}\n`, { flag: "a" }); |
| 931 | + | // G1T_TOUCH=0: they say what they wrote through the hooks, as cli() does. |
| 932 | + | writeFileSync(job.envFile, `export G1T_SESSION=${id}\nexport G1T_TOUCH=0\n`, { flag: "a" }); |
| 814 | 933 | | } catch {} |
| 815 | 934 | | } |
| 816 | 935 | | } |
| ⋯ |
| 1042 | 1161 | | const kept = readJson(files.unrecorded, {}) ?? {}; |
| 1043 | 1162 | | const output = sessionStart(event, env, findCli(env)); |
| 1044 | 1163 | | if (!output) { |
| 1045 | | − | 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; |
| 1046 | 1165 | | if (intent) write(files.unrecorded, JSON.stringify({ ...kept, title: intent })); |
| 1047 | 1166 | | return null; |
| 1048 | 1167 | | } |
| ⋯ |
| 1056 | 1175 | | const name = event?.hook_event_name; |
| 1057 | 1176 | | if (name === "PreToolUse") { |
| 1058 | 1177 | | if (!event.session_id || !existsSync(sessionFiles(event.session_id, env).marker)) return { output: null, job: null }; |
| 1178 | + | // The uncommitted files as a shell command finds them, so PostToolUse can say which it wrote. |
| 1179 | + | if (SHELL_TOOLS.has(event.tool_name) && event.tool_use_id) { |
| 1180 | + | const before = dirtyFiles(event.cwd || process.cwd()); |
| 1181 | + | if (before) write(sessionFiles(event.session_id, env).shell(event.tool_use_id), JSON.stringify(before)); |
| 1182 | + | } |
| 1059 | 1183 | | const output = preToolUse(event, env); |
| 1060 | 1184 | | const context = updateContext(event, env, { throttle: true }); |
| 1061 | 1185 | | if (!context) return { output, job: null }; |