SLOPSHOPPER

storage-guard

After an edit that keeps browser data in localStorage or sessionStorage, names each line, so the model stores the data in a cookie instead.

newguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · storage-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /storage-guard ⎿ storage-guard: on · mode note · no file is open ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

storage-guard

The model keeps a login token in localStorage because it is the shortest way, and any script on the page can now read it. This mod tells the model when an edit stores browser data with localStorage or sessionStorage instead of a cookie. The note comes with the Edit's result and names each line, so the model moves the data to a cookie in the same turn. By default nothing is stopped; in deny mode a commit, a push and a merge stop while a file still uses the storage.

What it does

  1. The mod hooks the Edit and Write tools. After a successful call on a JavaScript, TypeScript or component file (.js, .jsx, .ts, .tsx, .mjs, .cjs, .mts, .cts, .vue, .svelte, .astro, .html), it reads the lines the edit added: those of new_string that old_string does not have, or every line of a Write. Test files are read too.
  2. A line counts when it uses localStorage or sessionStorage as code:
CountsDoes not count
localStorage.setItem('token', token)// localStorage.setItem(...) and <!-- ... --> comment lines
window.sessionStorage.getItem(key)save(token) // not localStorage, a comment after the code
window['localStorage']"we never use localStorage", the word inside a string
const { sessionStorage } = window` localStorage is off `, the word in a template literal's text
` saved: ${localStorage.getItem(key)} , the ${...}` partmyLocalStorage, indexedDB, document.cookie
  1. The model reads this note after the Edit's result:

storage-guard: this edit stores data in the browser with localStorage or sessionStorage: src/auth.ts:12. Store it in a cookie instead (document.cookie, or the server's Set-Cookie).

The line number comes from the file after the edit; a Write is numbered from its own content. At most 8 places are named, the rest counted. When the file cannot be read, the path stands without a line and the error is logged once. The path is written against the git repository the session started in when the file is inside it, so a session opened in api/ names a file of web/ as web/auth.ts. Outside a git repository it is written against the directory the session started in. That root is read once at the session's start, because a Bash cd moves the session's own directory.

  1. At the same moment one line reaches the transcript, so you see what the model was told. The line holds the places alone, without the instruction:

storage-guard: browser storage instead of a cookie: src/auth.ts:12

The note and the line are separate channels: the model never reads the line, and you never read the note.

  1. While the sidebar is open, those places go there instead, one red line each, as an entry in its stream, and the transcript stays clean; the places past the eighth are one faint count. The entry stays until newer ones push it off the pane. With the sidebar closed, or without that mod installed, the transcript line is written as above.
  1. A finding is a claim about the file, never a remembered answer. The mod reads each open file again after every later Edit or Write, at the end of each main-loop turn, and before a guarded git command in deny mode, and every measure replaces the finding with what the file holds now: the reported lines that still use the storage, at their current line numbers. A line that is gone or commented out no longer counts. A partial fix keeps the finding open with the places left. A file whose lines are all gone closes, and so does a file that is no longer there; a file that is there and cannot be read keeps its finding, because an unread file proves nothing. The closing entry is green:

storage-guard: the browser storage is gone from src/auth.ts: src/auth.ts:12

With the sidebar closed the same text is one transcript line. The model reads nothing of this: it moved the data itself.

  1. A finding the model did not close is measured again at the end of each main-loop turn, and what is left reaches the model as one note with its next prompt:

storage-guard: 1 place(s) still store data in localStorage or sessionStorage: src/auth.ts:12. Move the data to a cookie, or take the lines out.

One note per turn, not one per prompt. Without this the finding would be said once, at the edit, and then stand in the pane while the model forgot it. You read nothing new: the pane already carries the same finding.

  1. In deny mode the mod also stops git commit, git push and git merge while a file still uses the storage; a command with --dry-run, --help or -h is not stopped. Before it stops one it reads each open file again, so a file the model fixed opens the gate itself. A git commit answers for its own files alone: the mod reads the index (git diff --cached --name-only -z) and lets the commit run when it holds none of the open files, with one line to you naming how many still stand. A push and a merge hold no index to read, so every finding stands there. There is no bypass; only you turn the gate off, with /storage-guard mode note. note mode is the default and stops nothing.

In the live check the model added localStorage.setItem('token', token) to a file with one Edit and quoted the note naming app.ts:2 word for word. At the next prompt it quoted the turn-end note, rewrote the line with document.cookie, and the closing line the browser storage is gone from app.ts: app.ts:2 followed that Edit.

Command

/storage-guard on or off, the mode, and the files still using the storage /storage-guard on | off on by default /storage-guard mode note note only; the default /storage-guard mode deny a commit, a push and a merge also stop while a file uses the storage

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install storage-guard@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Restart Claude Code.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, command.run{command=storage-guard}, turn.complete, prompt.submit, tool.call{tool=Bash}, tool.call{tool=Edit}, tool.call{tool=Write} ❯ ./register.ts calls: $.command.register, $.fs.exists (via isGone), $.fs.read (via fileText), $.process.run (via shownRootOf, stagedPaths), $.session.cwd, $.sidebar.clear (via dropEntry), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand, setMode), $.ui.log (via fileText, gate, toPerson)

Reach L2, it runs git to read the index.

  1. Reads: the text of each Edit and Write call; the Bash command text; the edited file after an Edit that adds the storage, for the line numbers, and each open file again while a finding stands
  2. Runs: git rev-parse --show-toplevel once at the session's start, to show paths against the repository root; git rev-parse --show-toplevel and git diff --cached --name-only -z, at a commit in deny mode, to read which files the commit holds
  3. Sends: a note to the model after an edit that adds the storage, one more with the next prompt while a finding stands, and one line to the transcript; nothing leaves the machine
  4. Persists: in $.store, the on/off setting and the mode
  5. Hostile input: the edited text is only matched by regular expressions and printed as file:line, never run

Limits

  • The check is line by line with regular expressions, not a parser. A string or a comment that spans several lines is read line by line, so a use inside it can count.
  • An HTML or component attribute in quotes (onclick="localStorage.clear()") is read as a string and does not count.
  • A storage reached through another name (const s = window[name], a wrapper library, indexedDB) is not seen.
  • An edit through Bash is not checked.
  • A finding closes when the reported lines are gone from the file or commented out. A line moved to another file keeps it open.
  • The deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /storage-guard mode note.
  • The gate reads the command text. A commit through a script or an alias that hides git commit is not stopped.
  • A git commit -a, a -am and a commit with a pathspec after -- are not narrowed to the index, because they commit files the index does not hold yet. Every open finding stands for those.
  • The index is read before the command runs. A commit whose files change between the read and the run is measured against what the index held at the read.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 257 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { denyText, doneLines, doneLog, isCommit, isGuarded, isNarrowable, isSource, logText, modeOf, noteText, openNote, placesOf, sectionKey, shownPath, sidebarLines, stillUsed, storageLines, type Line, type Mode } from './storage.ts'
3
4const ENABLED_KEY = 'enabled'
5const MODE_KEY = 'mode'
6
7const USAGE = 'expects nothing (the status), on, off or mode note | deny'
8
9/** One file's open finding: the file on disk, the lines that use the storage, and where they stand in it. */
10type Finding = { path: string; lines: string[]; places: string[] }
11
12/**
13 * The on/off setting and the mode as the store held them at the last read, whether a read error was
14 * logged, the open findings by shown path, whether the model is owed a note for them, and the root read
15 * once at the session's start (`shownRootOf`). A path is shown against that root, not against
16 * `$.session.cwd()`, because a Bash `cd` moves the session's directory and would then leave every path
17 * outside it written in full.
18 */
19type State = { enabled: boolean; mode: Mode; reported: boolean; open: Map<string, Finding>; owed: boolean; root?: string }
20
21/**
22 * Reads the on/off setting and the mode from the store, which every window shares, so a change made in
23 * another window applies here at the next hook that acts on it.
24 */
25async function readSettings($: EngineInterface, state: State): Promise<void> {
26  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
27  state.mode = (await $.store.get(MODE_KEY)) === 'deny' ? 'deny' : 'note'
28}
29
30/**
31 * The git repository the session started in, so a file in a sibling directory of a session opened in a
32 * subdirectory still reads short; the session's own directory where git does not answer.
33 */
34async function shownRootOf($: EngineInterface, cwd: string): Promise<string> {
35  try {
36    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
37    const root = top.stdout.trim()
38    return top.exitCode === 0 && root !== '' ? root : cwd
39  } catch {
40    // No git here, or the command did not run: paths are shown against the session's directory.
41    return cwd
42  }
43}
44
45/** Whether the file is no longer there, so a finding of it closes instead of standing for good. */
46async function isGone($: EngineInterface, path: string): Promise<boolean> {
47  try {
48    return !(await $.fs.exists(path))
49  } catch {
50    // The path was not measured: the finding is left as it stands.
51    return false
52  }
53}
54
55/** The file's text, or undefined when it cannot be read; the first failure is logged. */
56async function fileText($: EngineInterface, state: State, path: string): Promise<string | undefined> {
57  try {
58    return await $.fs.read(path)
59  } catch (err) {
60    if (!state.reported) $.ui.log(`the edited file was not read, its places stay as reported: ${err instanceof Error ? err.message : String(err)}`)
61    state.reported = true
62    return undefined
63  }
64}
65
66/**
67 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
68 * transcript line. The model's note is another channel and does not change here.
69 */
70async function toPerson($: EngineInterface, key: string, title: string, lines: Line[], line: string): Promise<void> {
71  try {
72    const taken = await $.sidebar.set({ consumer: 'storage-guard', key: sectionKey(key), title, lines, until: 'stream' })
73    if (taken) return
74  } catch {
75    // The sidebar mod is not installed.
76  }
77  $.ui.log(line)
78}
79
80/** Drops the sidebar entries of one finding, so a file that no longer uses the storage leaves no warning behind. */
81async function dropEntry($: EngineInterface, key: string): Promise<void> {
82  try {
83    await $.sidebar.clear({ consumer: 'storage-guard', key: sectionKey(key) })
84  } catch {
85    // The sidebar mod is not installed.
86  }
87}
88
89/**
90 * Reads each open file again. A finding whose lines are all gone closes; one that keeps some of them is
91 * replaced by what the file holds now, never joined with what it held before. An unreadable file stays
92 * as it stands. `skip` is the file this edit just measured, so the mod does not read it a second time.
93 */
94async function closeResolved($: EngineInterface, state: State, skip?: string): Promise<void> {
95  for (const [shown, found] of [...state.open]) {
96    if (shown === skip) continue
97    // A file that is gone holds no line any more; one that is there and unreadable proves nothing.
98    const text = (await isGone($, found.path)) ? '' : await fileText($, state, found.path)
99    if (text === undefined) continue
100    const left = stillUsed(text, found.lines)
101    if (left.length > 0) {
102      state.open.set(shown, { path: found.path, lines: left, places: placesOf(shown, text, left) })
103      continue
104    }
105    state.open.delete(shown)
106    await dropEntry($, shown)
107    await toPerson($, shown, 'cookie used instead', doneLines(shown, found.places), doneLog(shown, found.places))
108  }
109}
110
111/** The note of one edit and the file it named, and the finding it opens; undefined when the edit adds no storage use. */
112async function noteFor($: EngineInterface, state: State, path: string, before: string, after: string): Promise<{ note: string; shown: string } | undefined> {
113  const lines = storageLines(before, after)
114  if (lines.length === 0) return undefined
115  const shown = shownPath(path, state.root ?? (await $.session.cwd()))
116  const text = before === '' ? after : await fileText($, state, path)
117  const held = state.open.get(shown)?.lines ?? []
118  // The lines reported before are measured in the file as it is now, then this edit's lines join them.
119  const kept = text === undefined ? held : stillUsed(text, held)
120  const all = [...new Set([...kept, ...lines])]
121  state.open.set(shown, { path, lines: all, places: placesOf(shown, text, all) })
122  const places = placesOf(shown, text, lines)
123  // The note goes to the model, the line to the person: neither reads the other's channel.
124  await toPerson($, shown, 'browser storage instead of a cookie', sidebarLines(places), logText(places))
125  return { note: noteText(places), shown }
126}
127
128/** Adds the note to an edit whose new lines use the storage, and closes what a later edit fixed. */
129async function afterEdit($: EngineInterface, state: State, path: string, before: string, after: string, r: ToolCallResult): Promise<ToolCallResult> {
130  if (r.deny !== undefined || r.isError === true) return r
131  await readSettings($, state)
132  if (!state.enabled) return r
133  const found = isSource(path) ? await noteFor($, state, path, before, after) : undefined
134  await closeResolved($, state, found?.shown)
135  return found === undefined ? r : { ...r, context: [...(r.context ?? []), found.note] }
136}
137
138/**
139 * The files this commit holds, by absolute path, or undefined when git did not answer. Read before the
140 * command runs, so it is the index as the commit will take it.
141 */
142async function stagedPaths($: EngineInterface, state: State): Promise<Set<string> | undefined> {
143  try {
144    const cwd = state.root ?? (await $.session.cwd())
145    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
146    const staged = await $.process.run(['git', 'diff', '--cached', '--name-only', '-z'], { cwd })
147    if (top.exitCode !== 0 || staged.exitCode !== 0) return undefined
148    const base = top.stdout.trim()
149    return new Set(staged.stdout.split('\0').filter(Boolean).map(p => `${base}/${p}`))
150  } catch {
151    // No git here, or the command did not run: the findings are not narrowed.
152    return undefined
153  }
154}
155
156/**
157 * The findings this command answers for. A `git commit` answers for its own files alone, so a finding of
158 * a file the commit does not hold lets it run. A `push` or a `merge` holds no index to read, so every
159 * finding stands there.
160 */
161async function scopeOf($: EngineInterface, state: State, command: string): Promise<Finding[]> {
162  const all = [...state.open.values()]
163  if (!isCommit(command) || !isNarrowable(command)) return all
164  const staged = await stagedPaths($, state)
165  return staged === undefined ? all : all.filter(f => staged.has(f.path))
166}
167
168/**
169 * The gate of the `deny` mode: it reads each open file again, so a file the model fixed without a new
170 * finding opens the gate too. A file this command holds that still uses the storage stops it, and there
171 * is no bypass.
172 */
173async function gate($: EngineInterface, state: State, command: string): Promise<string | undefined> {
174  if (state.open.size === 0 || !isGuarded(command)) return undefined
175  await readSettings($, state)
176  if (!state.enabled || state.mode !== 'deny') return undefined
177  await closeResolved($, state)
178  if (state.open.size === 0) return undefined
179  const scoped = await scopeOf($, state, command)
180  if (scoped.length === 0) {
181    $.ui.log(`${state.open.size} file(s) still use localStorage or sessionStorage, and this command holds none of them`)
182    return undefined
183  }
184  return denyText(scoped.flatMap(f => f.places))
185}
186
187async function setMode($: EngineInterface, state: State, word: string): Promise<string> {
188  const mode = modeOf(word)
189  if (mode === undefined) return 'mode expects note or deny'
190  await $.store.set(MODE_KEY, mode)
191  state.mode = mode
192  return mode === 'deny' ? 'mode deny: git commit, push and merge stop while a file uses localStorage or sessionStorage' : 'mode note: the places are only reported'
193}
194
195function statusText(state: State): string {
196  const open = state.open.size === 0 ? 'no file is open' : `${state.open.size} file(s) still use localStorage or sessionStorage`
197  return `${state.enabled ? 'on' : 'off'} · mode ${state.mode} · ${open}`
198}
199
200async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
201  const word = args.trim()
202  if (word === 'on' || word === 'off') {
203    await $.store.set(ENABLED_KEY, word === 'on')
204    state.enabled = word === 'on'
205    return word === 'on' ? 'on: each edit is checked for localStorage and sessionStorage' : 'off: edits are not checked'
206  }
207  if (word.startsWith('mode')) return setMode($, state, word.slice(4).trim())
208  if (word !== '') return USAGE
209  await readSettings($, state)
210  return statusText(state)
211}
212
213export const register: Register = on => {
214  const state: State = { enabled: true, mode: 'note', reported: false, open: new Map(), owed: false }
215
216  on('session.start', async ($, e, next) => {
217    const r = await next(e)
218    await $.command.register({ name: 'storage-guard', description: 'localStorage and sessionStorage an edit adds: status, on, off, mode (storage-guard)', argumentHint: '[on | off | mode note | deny]' })
219    await readSettings($, state)
220    state.root = await shownRootOf($, await $.session.cwd())
221    return r
222  })
223
224  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
225  on('command.run', { command: 'storage-guard' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
226
227  /*
228   * The turn's end reads every open file again and owes the model a note for what is left, because a
229   * finding it did not close would otherwise stand in the pane and reach it never again.
230   */
231  on('turn.complete', async ($, e, next) => {
232    const r = await next(e)
233    if (e.agentId !== undefined) return r
234    await readSettings($, state)
235    if (!state.enabled) return r
236    await closeResolved($, state)
237    state.owed = state.open.size > 0
238    return r
239  })
240
241  // The note goes to the model alone; the person reads the pane, which carries the same finding.
242  on('prompt.submit', async (_, e, next) => {
243    if (!state.owed || state.open.size === 0) return next(e)
244    state.owed = false
245    const note = openNote([...state.open.values()].flatMap(f => f.places))
246    return next({ ...e, context: [...(e.context ?? []), note] })
247  })
248
249  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
250    const stop = await gate($, state, e.command)
251    return stop === undefined ? next(e) : { deny: stop }
252  })
253
254  on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, state, e.file_path, e.old_string, e.new_string, await next(e)))
255  on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, state, e.file_path, '', e.content, await next(e)))
256}
257
hooks/storage.ts 167 lines
1/** Lines of source code that keep browser data in localStorage or sessionStorage instead of a cookie. */
2
3/** Files whose lines are read: JavaScript, TypeScript and the component files that hold them. */
4const SOURCE = /\.(js|jsx|ts|tsx|mjs|cjs|mts|cts|vue|svelte|astro|html)$/
5
6/** The storage named as code: `localStorage.setItem`, `window.sessionStorage`, `{ localStorage }`. */
7const STORAGE = /\b(local|session)Storage\b/
8
9/** The storage named in brackets: `window['localStorage']`. */
10const BRACKET = /\[\s*(['"`])(local|session)Storage\1\s*\]/
11
12/** A line that starts as a comment in JavaScript, TypeScript or HTML. */
13const COMMENT = /^\s*(\/\/|\*|\/\*|<!--)/
14
15/** Quoted string literals, template literals, and what is left of a line after a comment starts on it. */
16const QUOTED = /'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/g
17const TEMPLATE = /`(?:\\.|[^`\\])*`/g
18const INTERPOLATION = /\$\{[^}]*\}/g
19const TAIL_COMMENT = /\/\/.*$|\/\*.*?\*\//g
20
21export function isSource(path: string): boolean {
22  return SOURCE.test(path)
23}
24
25/** The line without its string text; a template literal keeps its `${...}` parts, which are code. */
26function codeOf(line: string): string {
27  return line
28    .replace(TEMPLATE, t => (t.match(INTERPOLATION) ?? []).join(' '))
29    .replace(QUOTED, '""')
30    .replace(TAIL_COMMENT, '')
31}
32
33/** Whether the line uses the storage as code; a word inside a string or a comment is not a use. */
34function usesStorage(line: string): boolean {
35  if (COMMENT.test(line)) return false
36  return BRACKET.test(line) || STORAGE.test(codeOf(line))
37}
38
39/** The reported lines the file still uses the storage on; a line now commented out counts as fixed. */
40export function stillUsed(text: string, lines: string[]): string[] {
41  const used = text.split('\n').filter(usesStorage)
42  return lines.filter(l => used.some(u => u.includes(l)))
43}
44
45/** The trimmed lines of `after` that use the storage, without those `before` already had. */
46export function storageLines(before: string, after: string): string[] {
47  const old = new Set(before.split('\n').map(l => l.trim()))
48  const found = after.split('\n').filter(usesStorage).map(l => l.trim())
49  return [...new Set(found)].filter(l => l !== '' && !old.has(l))
50}
51
52/** The 1-based number of the first line of `text` that holds `line`; an Edit's text can be part of a line. */
53export function lineOf(text: string, line: string): number | undefined {
54  const i = text.split('\n').findIndex(l => l.includes(line))
55  return i < 0 ? undefined : i + 1
56}
57
58/** Each line as a place in the file: `file:line`, or the file alone when the text was not read. */
59export function placesOf(shown: string, text: string | undefined, lines: string[]): string[] {
60  return lines.map(l => {
61    const n = text === undefined ? undefined : lineOf(text, l)
62    return n === undefined ? shown : `${shown}:${n}`
63  })
64}
65
66/** `path` shown relative to the session directory when it is inside it. */
67export function shownPath(path: string, cwd: string): string {
68  const base = `${cwd.replace(/\/+$/, '')}/`
69  return path.startsWith(base) ? path.slice(base.length) : path
70}
71
72/** At most this many places are named, the rest counted. */
73const MAX_NAMED = 8
74
75function namedPlaces(places: string[]): string {
76  const named = places.slice(0, MAX_NAMED)
77  if (places.length > MAX_NAMED) named.push(`${places.length - MAX_NAMED} more`)
78  return named.join(' · ')
79}
80
81export function noteText(places: string[]): string {
82  return `storage-guard: this edit stores data in the browser with localStorage or sessionStorage: ${namedPlaces(places)}. Store it in a cookie instead (document.cookie, or the server's Set-Cookie).`
83}
84
85/**
86 * The note the model reads at the next prompt while a finding stands, so a finding it did not close
87 * reaches it again instead of standing in the pane alone. The person reads the pane and needs no line.
88 */
89export function openNote(places: string[]): string {
90  return `storage-guard: ${places.length} place(s) still store data in localStorage or sessionStorage: ${namedPlaces(places)}. Move the data to a cookie, or take the lines out.`
91}
92
93/** The transcript line: the places alone, without the instruction the model reads. The engine adds the mod name. */
94export function logText(places: string[]): string {
95  return `browser storage instead of a cookie: ${namedPlaces(places)}`
96}
97
98/** A sidebar line of a finding or its closing; the count of the places left unnamed is faint. */
99export type Line = { text: string; kind: 'error' | 'ok' | 'dim' }
100
101/**
102 * One line per named place in the finding's colour, and the places past `MAX_NAMED` as one faint count,
103 * so the count does not read as one more place.
104 */
105function placeLines(places: string[], kind: 'error' | 'ok'): Line[] {
106  const named: Line[] = places.slice(0, MAX_NAMED).map(text => ({ text, kind }))
107  return places.length > MAX_NAMED ? [...named, { text: `${places.length - MAX_NAMED} more`, kind: 'dim' }] : named
108}
109
110/** One sidebar line per place, so the section reads as a list. */
111export function sidebarLines(places: string[]): Line[] {
112  return placeLines(places, 'error')
113}
114
115/** The transcript line of a finding a later edit closed. */
116export function doneLog(file: string, places: string[]): string {
117  return `the browser storage is gone from ${file}: ${namedPlaces(places)}`
118}
119
120/** The sidebar lines of a closed finding: the file, then the places the storage left. */
121export function doneLines(file: string, places: string[]): Line[] {
122  return [{ text: file, kind: 'ok' }, ...placeLines(places, 'ok')]
123}
124
125/** The global flags git takes before the subcommand, so `git -c user.name=x commit` is still a commit. */
126const GIT_FLAG = String.raw`(?:\s+-[cC]\s+\S+|\s+--(?:git-dir|work-tree|namespace)=\S+|\s+--(?:no-pager|no-replace-objects|bare|literal-pathspecs|paginate))`
127
128/** A `git commit`, `git push` or `git merge` the gate stops while a finding is open. */
129const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
130const ASKING = /\s(--dry-run|--help|-h)(\s|$)/
131
132export function isGuarded(command: string): boolean {
133  return GUARDED.test(command) && !ASKING.test(command)
134}
135
136/** Whether the command is a `git commit`, the one guarded command whose own files can be measured. */
137export function isCommit(command: string): boolean {
138  return GUARDED.exec(command)?.[2] === 'commit'
139}
140
141/**
142 * Whether the index alone says what this commit holds. A `-a` or `-am` commit stages the tracked files
143 * as it runs, and a pathspec after `--` commits paths the index does not hold, so neither is narrowed.
144 */
145export function isNarrowable(command: string): boolean {
146  const words = command.split(/\s+/)
147  return !words.includes('--') && !words.some(w => w === '--all' || /^-[A-Za-z]*a/.test(w))
148}
149
150/** The mode of the mod: a note only, or a note and a gate on git commit, push and merge. */
151export type Mode = 'note' | 'deny'
152
153/** The mode a `/storage-guard mode <word>` argument names, or undefined when it is not one. */
154export function modeOf(arg: string): Mode | undefined {
155  return arg === 'note' || arg === 'deny' ? arg : undefined
156}
157
158/** The deny text both the model and the person read: where the storage is used, and the one way out. */
159export function denyText(places: readonly string[]): string {
160  return `stopped: ${places.length} place(s) store data in localStorage or sessionStorage: ${namedPlaces([...places])}. Move the data to a cookie, then run the command again; there is no way around this gate.`
161}
162
163/** A sidebar section key: the subject cut to what the sidebar takes, so one file keeps one section. */
164export function sectionKey(text: string): string {
165  return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
166}
167