SLOPSHOPPER

shared-repo-guard

For repos where several AI agents (or you and an agent) work in the same folder: stops secret values from leaving your .env, pushes to public remotes, and git…

newbandguardcommandtoastprocess
v0.1.2MITupdated 2026-10-09naoanao/shared-repo-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · shared-repo-guard
› fix the failing auth test and add an audit log call ╭───────────────────────────────────────────╮ │ shared-repo-guard │ ⏺ Read(src/auth.ts) │ shared-repo-guard stopped: This prints a │ ⎿ Read 6 lines ╰───────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(cat .env) ⎿ Denied by shared-repo-guard: shared-repo-guard: This prints a .env file. Read the value inside a script an ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /guard ⎿ shared-repo-guard: Secret values watched: 0 ⎿ shared-repo-guard: Public remotes: none set ⎿ shared-repo-guard: Stopped this session: 1 ⎿ shared-repo-guard: Uncommitted files this session did not write (protected): 1 ⎿ shared-repo-guard: src/auth.ts ⎿ shared-repo-guard: ?? src/auth.test.ts shared-repo-guard: 1 uncommitted file(s) from outside this session are protected (/guard) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
shared-repo-guard: 1 uncommitted file(s) from outside this session are protected (/guard)
README

shared-repo-guard

A Claude Code mod for repos where more than one AI agent works in the same folder.

I run Claude Code and a second coding agent on one repository, often at the same time and under the same git name. Over two years that setup produced these incidents:

  • An agent wrote a live API key straight into a shell command.
  • An admin secret ended up in a URL query string and was copied into a work report.
  • One agent cleaned up the working tree and deleted the other agent's uncommitted work. It could not be recovered.
  • A script rewrote line endings, and a 29-line change became a 2,356-line diff.

Popular guard collections already block reading .env and force-pushing to main. They don't cover the incidents above, so this mod handles those.

What it stops

What happensWhy
Secret valuesAny shell command, file write, or tool call (browser, MCP and others) that contains the actual value of a secret from your .env is refused. If a value turns up in a command's output, it is masked before the model sees it.Blocking the .env file isn't enough once the value has been copied somewhere else.
Other agents' workgit reset --hard, checkout ., restore, stash and clean -f ask you first when the tree holds uncommitted files that this session did not write. They are refused when nobody is there to answer. git add -A (and the same thing written as ., ./, :/ or *) is refused in the same situation.Another agent's half-finished work looks like junk to the agent that didn't write it.
Public remotesPushes to remotes you mark as public are refused, and so are push --all and --mirror.A repo with a private origin and a public mirror is one typo away from publishing everything.
Printing .envcat .env and similar commands are refused, and so is opening .env with the Read tool. .env.example is allowed.

Commands

  • /guard shows how many secret values are watched, which remotes are marked public, what was stopped, and which uncommitted files are protected.
  • /preflight runs before you push. It checks unpushed commits for line-ending churn, secret-looking strings (Stripe, OpenAI/Anthropic, GitHub, Google, AWS, private keys, JWTs), committed .env files, and files this session did not write.

While protected files exist, a quiet line above the prompt shows how many there are.

Install

/plugin marketplace add naoanao/shared-repo-guard
/plugin install shared-repo-guard@shared-repo-guard

The install screen asks for two optional settings:

  • Public remotes: remote names that must never be pushed to, for example public, mirror.
  • Extra .env files: .env files below the repository root, for example apps/web/.env.local. Every .env* file at the root is watched automatically.

How it works

  • Secret values are read from your .env files when the session starts. They are kept in memory only and never written, logged, or shown. A key counts as a secret when its name contains SECRET, KEY, TOKEN, PASSWORD, PRIVATE, CREDENTIAL, WEBHOOK, SIGNING, AUTH, DSN or DATABASE_URL and its value is at least 12 characters long. Public build variables (NEXT_PUBLIC_, VITE_, PUBLIC_, EXPO_PUBLIC_, REACT_APP_) are skipped.
  • A file counts as "written by this session" when it was changed through Claude's Write or Edit tools. Every other uncommitted change is treated as someone else's. If this session changed a file through a shell command instead, you get a question rather than a silent loss.
  • Commit messages are text, not commands, so words in them do not trigger a rule: the -m "..." of git commit, and a here-document given to git commit or written to a file. A message holding $(...) or backticks is still checked, because the shell runs those.
  • If the guard itself fails while checking a shell command, the command does not run.

What it reads, runs, and sends

  • Sends nothing. The mod makes no network requests and does not upload, log, or store anything. It has no telemetry.
  • Reads:
  • .env* files in the repository root, plus any files you list under "Extra .env files". It reads them only to learn the secret values to watch for. The values stay in memory and are compared against what tools are about to run or return.
  • Each tool call's input and output (shell commands, file writes, and other tool arguments). It looks for those secret values there and masks them in output.
  • Runs: only git, through its own read-only queries:
  • git rev-parse --show-toplevel to find the repository root
  • git rev-parse --abbrev-ref --symbolic-full-name @{u} to find the upstream branch
  • git status --porcelain=v1 -z --untracked-files=all to list uncommitted files
  • git log <upstream>..HEAD, plus git diff (with --shortstat, --ignore-cr-at-eol --shortstat and --name-only against the upstream) for /preflight

None of these change your repository.

  • Changes:
  • It refuses tool calls, using the rules above.
  • It asks you before destructive git commands.
  • It replaces secret values in tool output with [shared-repo-guard: secret hidden].
  • Hooks:
  • tool.call on Bash, PowerShell, Read, Write, Edit and every other tool, for the checks above
  • session.start and turn.complete to refresh the list of uncommitted files
  • ui.render for the one-line notice above the prompt
  • command.run for /guard and /preflight

How it differs from similar mods

  • blast-radius (Anthropic sample) holds rm -rf and force pushes. This mod targets a different loss: uncommitted work that belongs to someone else.
  • secret-redactor and claude-code-redact mask secrets in what the model reads. This mod also refuses commands, file writes and tool calls that would carry a secret value out.
  • Collision Guard asks before another chat edits a file that was changed recently. This mod covers other agents and tools that never pass through Claude Code. It protects any uncommitted file this session did not write.

Limits

  • It only sees what passes through Claude Code. Other agents and your own terminal are not covered.
  • Shell commands are parsed roughly: ;, &&, || and new lines are split, but quoting is not. That way it errs toward stopping.
  • It is not a secret scanner for your whole history. Use gitleaks or GitHub secret scanning for that.

Tests

claude plugin test .

There are 13 tests. They cover the rules plus the whole mod running in Claude Code's test engine, with a fake git and a fake .env. Each guard was broken on purpose once to confirm that its test fails for the right reason.

About

Made by nao, an AI consultant who builds. I design and run AI automation for small businesses, from first conversation to production. Portfolio: https://growl-ai.com/portfolio/en

Also by me: agent-cross-check. When another coding agent commits to your repo, Claude notices and audits the commits by diff and tests, not by the agent's report.

MIT License.


日本語

同じフォルダで複数の AI に作業させている人のための、Claude Code の見張りです。

止めるものは次のとおりです。

  • 鍵の値:.env にある鍵の値が、コマンド・ファイル・ほかの道具に入ろうとしたら止めます。コマンドの出力に鍵の値が出たときは、伏せ字にしてから AI に渡します。
  • 別の AI の作業:このセッションが書いていない保存前の変更があるとき、git reset --hard などの消すコマンドは、実行する前にあなたに確かめます。答える人がいなければ止めます。git add -A(.・./・:/・* と書いた場合も)も同じ状況では止めます。保存のメモの中の文字では止めません($(…) を含むメモは確かめます)。
  • 公開の送り先:公開と指定した送り先への送信を止めます。
  • .env の表示:.env の中身を画面に出すことを止めます。

入れ方は上の「Install」と同じです。/guard で今の見張りの状態を、/preflight で送る前の点検(改行の変化・鍵らしい文字・.env の混入・別の AI の作業)を見られます。

Source 2 files
hooks/register.tsx 191 lines
1import {
2  parseList, pushToPublic, addAll, destructiveGit, printsEnvFile, readsEnvFile,
3  secretValuesFrom, containsSecret, redact, statusPaths, relativeToRoot, foreignChanges,
4  secretPatternHits, lineEndingChurn,
5} from './rules.js'
6
7// Held only inside this module. Never written to the screen, a log or a file.
8let secrets = []
9let blocked = 0
10let root = ''
11let written = []
12let foreign = []
13let publicRemotes = []
14let extraEnvFiles = []
15
16async function git($, args) {
17  try {
18    const r = await $.process.run(['git', ...args], { cwd: root || undefined, timeoutMs: 20000 })
19    return r.exitCode === 0 ? String(r.stdout || '') : ''
20  } catch {
21    return ''
22  }
23}
24
25async function findRoot($) {
26  try {
27    const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { timeoutMs: 10000 })
28    root = r.exitCode === 0 ? String(r.stdout || '').trim() : ''
29  } catch {
30    root = ''
31  }
32}
33
34async function loadSecrets($) {
35  const found = new Set()
36  const base = root || (await $.session.cwd())
37  const files = new Set(extraEnvFiles.map((f) => base + '/' + f))
38  try {
39    for (const entry of await $.fs.list(base)) {
40      const n = String(entry?.name || '')
41      if (/^\.env(\.|$)/.test(n) && !/\.(example|sample|template)$/.test(n)) files.add(base + '/' + n)
42    }
43  } catch {
44    // an unreadable folder adds nothing
45  }
46  for (const f of files) {
47    try {
48      if (!(await $.fs.exists(f))) continue
49      for (const v of secretValuesFrom(await $.fs.read(f))) found.add(v)
50    } catch {
51      // an unreadable file is skipped
52    }
53  }
54  secrets = [...found]
55}
56
57async function refreshForeign($) {
58  if (!root) {
59    foreign = []
60    return
61  }
62  foreign = foreignChanges(statusPaths(await git($, ['status', '--porcelain=v1', '-z', '--untracked-files=all'])), written)
63  $.ui.invalidate('ui.render')
64}
65
66function listFew(paths) {
67  const head = paths.slice(0, 10).join('\n  ')
68  return '  ' + head + (paths.length > 10 ? '\n  ...and ' + (paths.length - 10) + ' more' : '')
69}
70
71function deny($, reason) {
72  blocked += 1
73  $.ui.toast('shared-repo-guard stopped: ' + reason.split('.')[0])
74  return { deny: 'shared-repo-guard: ' + reason }
75}
76
77async function preflight($) {
78  if (!root) return 'Not inside a git repository.'
79  const out = []
80  const upstream = (await git($, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'])).trim()
81  if (!upstream) {
82    out.push('No upstream branch, so there is nothing to compare against.')
83  } else {
84    const log = (await git($, ['log', upstream + '..HEAD', '--format=%h %ad %an %s', '--date=short'])).trim()
85    out.push('Unpushed commits on top of ' + upstream + ': ' + (log ? log.split('\n').length : 0))
86    if (log) {
87      out.push(log)
88      const churn = lineEndingChurn(
89        await git($, ['diff', '--shortstat', upstream, 'HEAD']),
90        await git($, ['diff', '--ignore-cr-at-eol', '--shortstat', upstream, 'HEAD']),
91      )
92      out.push('Line endings: ' + (churn ? 'CHANGED (diff shows ' + churn.shown + ' lines, real content ' + churn.real + ')' : 'unchanged'))
93      const hits = secretPatternHits(await git($, ['diff', upstream, 'HEAD']))
94      out.push('Secret-looking strings: ' + (hits.length ? hits.join(', ') : 'none'))
95      const files = (await git($, ['diff', '--name-only', upstream, 'HEAD'])).trim()
96      const envs = files.split('\n').filter((f) => /(^|\/)\.env(\.|$)/.test(f) && !/\.(example|sample|template)$/.test(f))
97      if (envs.length) out.push('.env files in the commits: ' + envs.join(', '))
98    }
99  }
100  await refreshForeign($)
101  out.push('Uncommitted files this session did not write: ' + foreign.length + (foreign.length ? '\n' + listFew(foreign) : ''))
102  return out.join('\n')
103}
104
105export function register(on, options) {
106  publicRemotes = parseList(options?.publicRemotes)
107  extraEnvFiles = parseList(options?.envFiles)
108
109  on('session.start', async ($, e, next) => {
110    await findRoot($)
111    await loadSecrets($)
112    await $.command.register({ name: 'guard', description: 'shared-repo-guard: what it watches and what it stopped' })
113    await $.command.register({ name: 'preflight', description: 'Check unpushed commits: line endings, secrets, .env files, other agents\' changes' })
114    await refreshForeign($)
115    return next(e)
116  })
117
118  on('turn.complete', async ($, e, next) => {
119    await refreshForeign($)
120    return next(e)
121  })
122
123  on('command.run', { command: 'guard' }, async ($) => {
124    await loadSecrets($)
125    await refreshForeign($)
126    return {
127      text: [
128        'Secret values watched: ' + secrets.length,
129        'Public remotes: ' + (publicRemotes.length ? publicRemotes.join(', ') : 'none set'),
130        'Stopped this session: ' + blocked,
131        'Uncommitted files this session did not write (protected): ' + foreign.length + (foreign.length ? '\n' + listFew(foreign) : ''),
132      ].join('\n'),
133    }
134  })
135
136  on('command.run', { command: 'preflight' }, async ($) => ({ text: await preflight($) }))
137
138  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
139    if (e.props.hasSurvey || foreign.length === 0) return next(e)
140    const { Text } = $.ui.resolve(e)
141    return <Text dimColor>shared-repo-guard: {foreign.length} uncommitted file(s) from outside this session are protected (/guard)</Text>
142  })
143
144  on('tool.call', { tool: ['Bash', 'PowerShell'] }, async ($, e, next) => {
145    const cmd = String(e.command || '')
146    const r = pushToPublic(cmd, publicRemotes) || printsEnvFile(cmd)
147    if (r) return deny($, r)
148    if (containsSecret(cmd, secrets)) return deny($, 'The command contains a secret value from your .env. Read it from the environment instead of writing it out.')
149    if (addAll(cmd)) {
150      await refreshForeign($)
151      if (foreign.length) return deny($, 'git add -A would stage ' + foreign.length + ' file(s) this session did not write. Add your own files by name.\n' + listFew(foreign))
152    }
153    const d = destructiveGit(cmd)
154    if (d) {
155      await refreshForeign($)
156      if (foreign.length) {
157        let answer = 'Stop'
158        try {
159          answer = await $.ui.ask(d + ' would throw away uncommitted work this session did not write (' + foreign.length + ' file(s)):\n' + listFew(foreign) + '\nRun it anyway?', ['Stop', 'Run anyway'])
160        } catch {
161          // nobody to answer: stop
162        }
163        if (answer !== 'Run anyway') return deny($, d + ' was stopped because it would discard work this session did not write. Leave those files alone.')
164      }
165    }
166    return redact(await next(e), secrets)
167  }).catch(async () => ({ deny: 'shared-repo-guard could not check this command, so it did not run.' }))
168
169  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
170    const r = readsEnvFile(e.file_path)
171    if (r) return deny($, r)
172    return redact(await next(e), secrets)
173  })
174
175  on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
176    const text = String(e.content || '') + '\n' + String(e.new_string || '')
177    if (containsSecret(text, secrets)) return deny($, 'This writes a secret value into a file. Refer to the variable name instead.')
178    const result = await next(e)
179    const rel = relativeToRoot(String(e.file_path || ''), root)
180    if (rel && !written.includes(rel)) written.push(rel)
181    return result
182  })
183
184  on('tool.call', { tool: /^(?!(Bash|PowerShell|Read|Write|Edit)$)/ }, async ($, e, next) => {
185    let text = ''
186    try { text = JSON.stringify(e) } catch { text = '' }
187    if (containsSecret(text, secrets)) return deny($, 'This passes a secret value to ' + e.tool + '.')
188    return next(e)
189  })
190}
191
hooks/rules.js 215 lines
1// Pure decision functions. No engine calls here, so the tests can call them directly.
2
3// Split a shell line on ; && || and newlines. Quotes are not parsed: rough, but it errs toward stopping.
4function splitCommands(cmd) {
5  return String(cmd || '').split(/;|&&|\|\||\n/)
6}
7
8// "word" and 'word' become word; quoted text with spaces is left as it is.
9function unquoteWords(s) {
10  return String(s || '').replace(/(["'])([^"'\s]+)\1/g, '$2')
11}
12
13// Drop commit messages, which are text and never run: the -m "..." of git commit, and the body of a
14// here-document given to git commit or written to a file. A here-document that is run (bash <<EOF) stays,
15// and so does any message holding $(...) or backticks, because the shell runs those.
16function withoutMessages(cmd) {
17  let s = String(cmd || '')
18  s = s.replace(/<<-?\s*(['"]?)(\w+)\1([^\n]*)\n([\s\S]*?)\n[ \t]*\2[ \t]*(?=\n|$)/g, (all, q, tag, rest, body, offset) => {
19    if (/\$\(|`/.test(body)) return all
20    const opener = s.slice(s.lastIndexOf('\n', offset) + 1, offset) + rest
21    return /\bgit\s+commit\b/.test(opener) || />/.test(opener) ? '<<' + tag + rest + '\n' + tag : all
22  })
23  if (/\bgit\s+commit\b/.test(s)) {
24    s = s.replace(/(\s(?:-m|--message)(?:=|\s+))("(?:[^"\\]|\\.)*"|'[^']*')/g, (all, flag, msg) => (/\$\(|`/.test(msg) ? all : flag + '""'))
25  }
26  return s
27}
28
29// Comma or whitespace separated list from a userConfig string.
30export function parseList(value) {
31  return String(value || '')
32    .split(/[,\s]+/)
33    .map((s) => s.trim())
34    .filter(Boolean)
35}
36
37// git push to a remote the user marked public, or push --all / --mirror.
38export function pushToPublic(cmd, publicRemotes) {
39  for (const raw of splitCommands(withoutMessages(cmd))) {
40    // The shell drops quotes, so "public" and 'public' push to the same remote as public.
41    // Only single quoted words are unwrapped, so a message like -m "push to public" stays a message.
42    const part = unquoteWords(raw)
43    if (!/\bgit\b/.test(part) || !/\bpush\b/.test(part)) continue
44    for (const r of publicRemotes || []) {
45      const esc = r.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
46      if (new RegExp('\\bpush\\b.*(^|\\s)' + esc + '(\\s|$|:)').test(part)) {
47        return 'This pushes to "' + r + '", which is marked as a public remote. Push to your private remote instead.'
48      }
49    }
50    if (/\s--(all|mirror)\b/.test(part)) return 'git push --all / --mirror sends every branch at once. Push the current branch only.'
51  }
52  return null
53}
54
55// git add -A / --all / . / ./ / :/ / * (stages everything, including other agents' work)
56export function addAll(cmd) {
57  for (const part of splitCommands(withoutMessages(cmd))) {
58    const m = part.match(/\bgit\s+add\b(.*)$/)
59    if (!m) continue
60    const args = ' ' + unquoteWords(m[1]) + ' '
61    if (/\s(-A|--all|\.\/?|:\/|\*|-[a-zA-Z]*A[a-zA-Z]*)\s/.test(args)) return 'git add -A'
62  }
63  return null
64}
65
66// git commands that throw away uncommitted work with no way back.
67export function destructiveGit(cmd) {
68  for (const part of splitCommands(withoutMessages(cmd))) {
69    if (!/\bgit\b/.test(part)) continue
70    if (/\bgit\s+reset\b.*\s--hard\b/.test(part)) return 'git reset --hard'
71    if (/\bgit\s+checkout\b.*\s--(\s|$)/.test(part)) return 'git checkout -- <file>'
72    if (/\bgit\s+checkout\s+\.(\s|$)/.test(part)) return 'git checkout .'
73    if (/\bgit\s+restore\b/.test(part) && (!/\s--staged\b/.test(part) || /\s--worktree\b/.test(part))) return 'git restore'
74    if (/\bgit\s+stash\b/.test(part) && !/\bgit\s+stash\s+(list|show)\b/.test(part)) return 'git stash'
75    if (/\bgit\s+clean\b.*\s-[a-zA-Z]*f/.test(part)) return 'git clean -f'
76  }
77  return null
78}
79
80// A shell command that prints a .env file to the screen.
81export function printsEnvFile(cmd) {
82  const reader = /\b(cat|type|Get-Content|gc|more|less|head|tail|bat|nl|strings|xxd|od)\b[^|;&\n]*?[\\/\s'"]\.env(\.[\w.-]*)?\b/i
83  return reader.test(String(cmd || ''))
84    ? 'This prints a .env file. Read the value inside a script and never print it.'
85    : null
86}
87
88// The Read tool opening a .env file (.env.example is a template, so it passes).
89export function readsEnvFile(path) {
90  if (typeof path !== 'string') return null
91  const name = path.split(/[\\/]/).pop() || ''
92  if (!/^\.env(\..+)?$/.test(name)) return null
93  if (/\.(example|sample|template)$/.test(name)) return null
94  return 'Opening a .env file puts its secrets into the conversation. Use the variable names only.'
95}
96
97// Key names that hold secrets. Public build-time variables are skipped.
98const SECRET_NAME = /(SECRET|KEY|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|WEBHOOK|SIGNING|AUTH|DSN|DATABASE_URL)/i
99const PUBLIC_NAME = /^(NEXT_PUBLIC_|VITE_|PUBLIC_|EXPO_PUBLIC_|REACT_APP_)/
100
101// Secret values from a .env body. Short values are skipped to avoid matching ordinary words.
102export function secretValuesFrom(text) {
103  const out = []
104  for (const raw of String(text || '').split(/\r?\n/)) {
105    const line = raw.trim()
106    if (!line || line.startsWith('#')) continue
107    const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)
108    if (!m) continue
109    const key = m[1]
110    let val = m[2].trim()
111    if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1)
112    if (PUBLIC_NAME.test(key) || !SECRET_NAME.test(key)) continue
113    if (val.length < 12) continue
114    out.push(val)
115  }
116  return out
117}
118
119export function containsSecret(text, secrets) {
120  if (!text || !secrets || secrets.length === 0) return false
121  for (const s of secrets) if (text.includes(s)) return true
122  return false
123}
124
125// Copy of a tool result with secret values masked (frozen input is never touched).
126export function redact(value, secrets) {
127  if (!secrets || secrets.length === 0) return value
128  if (typeof value === 'string') {
129    let v = value
130    for (const s of secrets) if (v.includes(s)) v = v.split(s).join('[shared-repo-guard: secret hidden]')
131    return v
132  }
133  if (Array.isArray(value)) return value.map((x) => redact(x, secrets))
134  if (value && typeof value === 'object') {
135    const o = {}
136    for (const k of Object.keys(value)) o[k] = redact(value[k], secrets)
137    return o
138  }
139  return value
140}
141
142// Paths from `git status --porcelain=v1 -z --untracked-files=all`. Renames give two entries; both count.
143export function statusPaths(z) {
144  const parts = String(z || '').split('\0').filter(Boolean)
145  const out = []
146  for (let i = 0; i < parts.length; i++) {
147    const entry = parts[i]
148    if (entry.length < 4) continue
149    out.push(normPath(entry.slice(3)))
150    if (entry[0] === 'R' || entry[0] === 'C') {
151      if (parts[i + 1]) out.push(normPath(parts[i + 1]))
152      i++
153    }
154  }
155  return out
156}
157
158// Forward slashes and lower case, so Windows and git spellings compare equal.
159export function normPath(p) {
160  return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
161}
162
163// A file path from a tool call, relative to the repository root, or null when it lies outside.
164export function relativeToRoot(filePath, root) {
165  const f = normPath(filePath)
166  const r = normPath(root).replace(/\/$/, '')
167  if (!r) return null
168  if (f.startsWith(r + '/')) return f.slice(r.length + 1)
169  return null
170}
171
172// Uncommitted paths this session did not write: another agent's or a person's work.
173export function foreignChanges(currentPaths, writtenBySession) {
174  const mine = new Set((writtenBySession || []).map(normPath))
175  return [...new Set((currentPaths || []).map(normPath))].filter((p) => !mine.has(p))
176}
177
178// Known secret formats in the added lines of a diff.
179export function secretPatternHits(diffText) {
180  const pats = {
181    'Stripe live key': /sk_live_[A-Za-z0-9]{8,}/g,
182    'Stripe test key': /sk_test_[A-Za-z0-9]{8,}/g,
183    'JWT': /eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}/g,
184    'GitHub token': /gh[pousr]_[A-Za-z0-9]{20,}/g,
185    'OpenAI / Anthropic key': /sk-(ant-)?[A-Za-z0-9_-]{20,}/g,
186    'Google API key': /AIza[0-9A-Za-z_-]{30,}/g,
187    'AWS access key': /AKIA[0-9A-Z]{16}/g,
188    'Private key block': /-----BEGIN [A-Z ]*PRIVATE KEY-----/g,
189  }
190  const added = String(diffText || '')
191    .split(/\r?\n/)
192    .filter((l) => l.startsWith('+') && !l.startsWith('+++'))
193    .join('\n')
194  const out = []
195  for (const [name, re] of Object.entries(pats)) {
196    const n = (added.match(re) || []).length
197    if (n > 0) out.push(name + ' x' + n)
198  }
199  return out
200}
201
202// Inserted plus deleted lines from `git diff --shortstat`.
203export function shortstatLines(s) {
204  const ins = Number((String(s).match(/(\d+) insertion/) || [])[1] || 0)
205  const del = Number((String(s).match(/(\d+) deletion/) || [])[1] || 0)
206  return ins + del
207}
208
209// A diff that is much bigger than its content once line endings are ignored.
210export function lineEndingChurn(plain, ignoringCr) {
211  const a = shortstatLines(plain)
212  const b = shortstatLines(ignoringCr)
213  return a !== b ? { shown: a, real: b } : null
214}
215