sf-docs-agent 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@ Needs Node 22+ and Claude Code.
28
28
  npx sf-docs-agent
29
29
  ```
30
30
 
31
- Then start a new Claude Code session. The agent is listed as `sf-docs`. Add `--project` to install it for the current project only.
31
+ Then start a new Claude Code session. The agent is listed as `sf-docs`. Add `--project` to install it for the current project only. Claude Code then runs the agent's hook only in a folder you trust: in one you have not trusted, for example under `claude -p`, the agent runs without it.
32
32
 
33
33
  <details>
34
34
  <summary>Headless browser, Linux, update, remove, cache</summary>
@@ -102,9 +102,9 @@ Every number, per question, with the method: [docs/benchmarks.md](https://github
102
102
  - Help, admin and some older developer guides have no `.md` copy, so those pages are slower. If Salesforce changes their layout, the agent may read only part of an article. A daily check in this repo flags it.
103
103
  - No Help search, because Help's `robots.txt` disallows it.
104
104
  - From a page it found, it follows at most two more links in a row, and only toward a missing part of the answer. Anything further comes back as a gap.
105
- - On a long list it sometimes stops a row early. The quotes it gives are still right.
105
+ - When a search term lands in a list or table, the script prints the whole list, one a page, up to 60 lines; a longer one it names by its line range. It cannot see a list on a rendered page, which has no list markers, and there the agent can still stop a row early. The quotes it gives are still right.
106
106
  - If a site blocks headless browsers, it stops and tells you. It does not try to get around the block.
107
- - The agent has Bash. Its instructions limit it to its script and cache, but that is an instruction to the model, not a sandbox.
107
+ - The agent has Bash and Read. A hook limits Bash to the agent's script and to `grep` on files in its install folder. It limits Read to that folder. The hook stops all other commands. It checks the text of each command before the command runs, so it is not an operating system sandbox.
108
108
  - No telemetry. Only the docs pages it fetches leave your machine.
109
109
 
110
110
  How it reads each site, and the full list: [docs/how-it-works.md](https://github.com/drsaavedra/sf-docs-agent/blob/main/docs/how-it-works.md)
package/agents/sf-docs.md CHANGED
@@ -3,6 +3,12 @@ name: sf-docs
3
3
  description: Answers a question from official Salesforce docs (developer, architect, help, admin and lightningdesignsystem.com) with verbatim quotes the script copies from the cached page, each tied to its official URL and line. Use it proactively whenever the user wants something read, scanned, checked or looked up in Salesforce documentation, even without naming this agent ("what does Help say about X", "check the Apex guide for Y", "scan the docs on Z"), and when you do not know which page holds the answer. Brief it with the question, the site or product if known, and the width ("the one concept page" or "the whole doc set"). Delegate even for one page you already know, with its URL in the brief: a page is thousands of tokens in your context and the agent's report is about 400. A URL on one of those sites in the question is for this agent too; do not WebFetch or curl it, those get a 403, an empty JavaScript shell or a raw HTML page. Show the user its Answer and Sources. To check a quote, run `grep -c -F -f - <cache path>` with the quote on stdin through a quoted heredoc (`<<'EOF'`), so an apostrophe in it cannot break the shell: one quote on one line, no blank line, since grep counts a blank line as matching every line. Reads docs only. Not for org state.
4
4
  model: claude-sonnet-5-5
5
5
  tools: Bash, Read
6
+ hooks:
7
+ PreToolUse:
8
+ - matcher: "Bash|Read"
9
+ hooks:
10
+ - type: command
11
+ command: 'node "/path/to/sf-docs-agent/guard.mjs" || exit 2'
6
12
  ---
7
13
 
8
14
  ## Role
@@ -30,7 +36,7 @@ node "/path/to/sf-docs-agent/fetch-docs.mjs" <url> [<url> ...]
30
36
 
31
37
  Each argument is a URL on one of the sites above (`https://` may be left off). Always write the host: a bare
32
38
  `/docs/...` path means developer.salesforce.com, but Git Bash on Windows rewrites a leading `/` into a Windows path.
33
- Quote any URL that holds `?` or `&`. Pass many URLs in one call: it opens one browser.
39
+ Quote any URL that holds `?`, `&` or `#`. Pass many URLs in one call: it opens one browser.
34
40
  Pass `--budget 480` on every fetch and `timeout: 600000` on its Bash call. The script starts no page after 480 s and
35
41
  prints `not fetched (time limit) <url>` for each one left: rerun only those URLs. A call that passes the Bash limit is
36
42
  moved to the background and you lose its output, though every page it printed is cached. Up to 20 URLs a call; past
@@ -55,6 +61,8 @@ that, the printed output is the limit, not time.
55
61
  term, numbered, under `== <file>`, then the matching lines of its `.links.txt`. Past 20 lines (10 for links), those with
56
62
  the rarest terms win: add a term to narrow. A page of up to 40 lines and 4,000 chars prints whole,
57
63
  marked `(short page, shown whole)`: read it all before saying it lacks something. To search a page you already have for other terms, fetch it again with them; it comes from the cache.
64
+ When matches land in a list or table, one a page prints whole, marked `(list or table, lines a-b, shown whole)`;
65
+ a longer one prints `(list or table, lines a-b, ...: too long to show; Read a-b)`.
58
66
  `--search 'a,b'` [`--site <host>`] prints the same ranked lines across every cached page except indexes and
59
67
  sitemaps, at most 3 a file, with the file.
60
68
  - Never pass `--out`. The cache dir every run shares is
@@ -84,11 +92,12 @@ Every tool call costs you the whole conversation again, so use as few Bash calls
84
92
  2. **Fetch and locate.** Fetch the pages you picked with `--terms`. On Help or LDS, the `.links.txt` matches name the
85
93
  next page.
86
94
  3. **Look.** To see the lines around a match, use Read with `offset` and `limit`, or `grep -n -C3 -e '<term>' <file>`:
87
- both number lines as `--quote` counts them. `sed -n` and `head` do not, so never take a line number from them.
95
+ both number lines as `--quote` counts them.
88
96
  Leave quote marks out of a grep term and search the words on either side: a `'` ends the shell quote, and a
89
97
  curly one from the question misses a page saved with straight ones.
90
- Never read a page whole. When the answer is a list or a table (patterns, editions, values), Read from its first
91
- item to the next heading before you quote: a `--terms` match list stops at its cap, and the last rows may not
98
+ Never read a page whole. When the answer is a list or a table (patterns, editions, values), quote it from the
99
+ `(list or table ...)` block `--terms` printed. Read only a range it names as too long to show, or, when no block
100
+ printed, from the list's first item to the next heading: a match list stops at its cap, and the last rows may not
92
101
  hold your term.
93
102
  4. **Two hops at most.** If a matched line, a `.links.txt` match or a sentence you will quote names another page that
94
103
  holds part of the answer (a child article, a "Limitations for ..." heading with no text under it, an index entry
@@ -110,7 +119,10 @@ Every tool call costs you the whole conversation again, so use as few Bash calls
110
119
  JavaScript shell on Help, or raw HTML with no Source line for `--quote`.
111
120
  - If the script prints a `BLOCKED:` line, or Node cannot find `playwright-core`, stop and report the error lines as
112
121
  printed (after `BLOCKED:` they include the install command). Do not install anything yourself.
113
- - Bash is for running the script and reading the cache dir. Write nothing anywhere else.
122
+ - A hook limits Bash to the script and to `grep` on files in the script's dir, and Read to files in that dir. Join
123
+ commands only with `&&`. Use absolute paths and name each file: no unquoted wildcards or `#`. grep takes short
124
+ options only (no `--`), `-r` but not `-R`. No pipes, `cd`, `;`, shell variables, loops, `eval` or redirects other
125
+ than `2>&1`: write each URL out in full, up to 20 a call. A denied call says why; change the call.
114
126
  - Run every command in the foreground and wait for it. Never run one in the background or wait on a monitor: your
115
127
  last message is the report, so a run that ends while a fetch is still going returns no answer.
116
128
  - Only the sites above: the script refuses any other host. A third-party blog or forum is never evidence, even when a
package/bin/install.mjs CHANGED
@@ -16,6 +16,7 @@ const agents = project ? resolve('.claude', 'agents') : join(config, 'agents');
16
16
 
17
17
  mkdirSync(runtime, { recursive: true });
18
18
  cpSync(join(pkg, 'fetch-docs.mjs'), join(runtime, 'fetch-docs.mjs'));
19
+ cpSync(join(pkg, 'guard.mjs'), join(runtime, 'guard.mjs')); // the agent's PreToolUse hook, named in its frontmatter
19
20
  // A devDependency: npx would otherwise download playwright-core once for the installer and again for the runtime.
20
21
  const version = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')).devDependencies['playwright-core'];
21
22
  writeFileSync(join(runtime, 'package.json'),
package/fetch-docs.mjs CHANGED
@@ -89,31 +89,126 @@ const matches = (name, text, t) => text.split('\n').flatMap((line, i, ls) => {
89
89
  const low = i || !ls[0].startsWith('Source: ') ? plain(line).toLowerCase() : '', hit = t.filter(s => low.includes(s)); // a page's line 1 is its Source line; a sidecar has none
90
90
  return hit.length ? [{ name, n: i + 1, line, hit, at: Math.min(...hit.map(s => low.indexOf(s))) }] : [];
91
91
  });
92
- const rank = (ms, max = MAX_HITS) => {
93
- if (ms.length <= max) return ms;
92
+ const scorer = ms => {
94
93
  const df = Object.create(null); // a term like 'constructor' must not read Object.prototype
95
94
  for (const m of ms) for (const s of m.hit) df[s] = (df[s] || 0) + 1;
96
- const score = m => m.hit.reduce((a, s) => a + 1 / df[s], 0);
95
+ return m => m.hit.reduce((a, s) => a + 1 / df[s], 0);
96
+ };
97
+ const rank = (ms, max = MAX_HITS, score = scorer(ms)) => {
98
+ if (ms.length <= max) return ms;
97
99
  return ms.map((m, k) => [score(m), k, m]).sort((a, b) => b[0] - a[0] || a[1] - b[1]).slice(0, max)
98
100
  .sort((a, b) => a[1] - b[1]).map(x => x[2]);
99
101
  };
100
102
  const show = m => `${m.n}: ${m.line.length > 300 ? `...${m.line.slice(Math.max(0, m.at - 100), m.at + 200)}...` : m.line}`;
101
103
  const more = (all, top) => all.length > top.length ? [`(${all.length - top.length} more lower-ranked lines: add a term to narrow)`] : [];
104
+
105
+ // A list or table a match lands in prints whole, so no reading window ends a row short. One block a page: three
106
+ // doubled the benchmark's output with short lists nobody asked about.
107
+ const BLOCKS = 1, BLOCK_LINES = 60, BLOCK_LINE = 1000, BLOCK_CHARS = 6000, BLOCK_MAX = 200;
108
+ const BOLD = /^\*\*[^*]+\*\*:?\s*$/, HEAD = new RegExp(`^#{1,6}\\s|${BOLD.source}`);
109
+ // Code fences, CommonMark: opened by ``` (no backtick after it) or ~~~, closed by a bare line of the same character, at
110
+ // least as long; [open, close] line pairs. An opener that never closes fences the rest of the page, or with skipStray
111
+ // is taken as stray text (rendered Help has them) and the scan goes on after it.
112
+ const fences = (ls, skipStray) => {
113
+ const out = [];
114
+ for (let i = 0; i < ls.length; i++) {
115
+ const f = /^\s*(`{3,}(?=[^`]*$)|~{3,})/.exec(ls[i])?.[1];
116
+ if (!f) continue;
117
+ let j = i + 1;
118
+ for (let c; j < ls.length; j++) if ((c = /^\s*(`{3,}|~{3,})\s*$/.exec(ls[j])?.[1]) && c[0] === f[0] && c.length >= f.length) break;
119
+ if (j < ls.length) { out.push([i, j]); i = j; } else if (!skipStray) { out.push([i, ls.length - 1]); break; }
120
+ }
121
+ return out;
122
+ };
123
+ // Rows: markdown table and list lines, rendered table lines (tabs), and paragraphs that open with a link or are only
124
+ // bold or italic text (a table or term list flattened, as on the architect pages).
125
+ const rowKind = l => {
126
+ if (/^\s*\|/.test(l)) return '|';
127
+ if (l.includes('\t')) return '\t'; // before list markers: a rendered cell can start with '-' or '1.'
128
+ const li = /^(\s*)(?:[-*+]|\d+[.)])\s/.exec(l);
129
+ if (li) return `-${li[1].length}`; // a deeper item continues the one above it
130
+ return /^\[[^\]]+\]\(/.test(l) ? '[' : BOLD.test(l) ? '**' : /^_[^_]+_\s*$/.test(l) ? '_' : '';
131
+ };
132
+ // Paragraph rows may have a description of up to two lines between them; other rows none.
133
+ const PARAS = new Set(['[', '**', '_']);
134
+ // A rendered cell spills over lines up to the next tab row when that row opens with a tab.
135
+ const spills = (ls, j) => { for (let n = j; n < Math.min(ls.length, j + 30); n++) if (ls[n].includes('\t')) return ls[n].startsWith('\t'); return false; };
136
+ // Blocks of 3 or more rows of one kind, as 1-based [first, last] lines; a rendered row ending in a tab has an open
137
+ // cell, which spills over up to 30 lines.
138
+ const blocksOf = ls => {
139
+ // A fence is one unit, never rows (a doc comment's ' * ' lines read as a list). A stray opener is skipped here;
140
+ // lean keeps it as code to the end, which leaves the text untouched.
141
+ const fenced = ls.map(() => false), close = [];
142
+ for (const [o, c] of fences(ls, true)) { fenced.fill(true, o, c + 1); close[o] = c; }
143
+ const out = [];
144
+ for (let i = 1; i < ls.length; i++) {
145
+ const k = rowKind(ls[i]), indent = k[0] === '-' ? Number(k.slice(1)) : -1;
146
+ if (!k) continue;
147
+ let end = i, rows = 1, gap = 0, described = false, open = k === '\t' && ls[i].endsWith('\t') ? i : 0, j = i + 1;
148
+ for (; j < ls.length; j++) {
149
+ const l = ls[j];
150
+ if (!l.trim()) continue;
151
+ const deeper = indent >= 0 && /^\s*/.exec(l)[0].length > indent;
152
+ if (close[j] !== undefined && deeper) { end = j = close[j]; continue; } // code under the item: skip it whole
153
+ if (fenced[j]) break;
154
+ const rk = rowKind(l);
155
+ if (rk === k) { rows++; end = j; described ||= gap > 0; gap = 0; open = k === '\t' && l.endsWith('\t') ? j : 0; continue; }
156
+ if ((open && j - open <= 30) || deeper || (k === '\t' && spills(ls, j))) { end = j; continue; }
157
+ if (HEAD.test(l) || rk || ++gap > (PARAS.has(k) ? 2 : 0)) break; // a row of another kind starts its own run
158
+ }
159
+ if (described) for (let t = end + 1; t < j; t++) if (ls[t].trim()) end = t; // the last row's description
160
+ if (rows >= 3) {
161
+ // Lead-in: up to 2 caption lines, then a heading right above them (Q50 has both).
162
+ let a = i;
163
+ for (let t = i - 1, n = 0; t >= 1 && t > (out.at(-1)?.[1] ?? 0) - 1; t--) { // not into the block above
164
+ if (fenced[t]) break;
165
+ if (!ls[t].trim()) continue;
166
+ if (HEAD.test(ls[t])) { a = t; break; } // before rowKind: a bold caption also reads as a bold-term row
167
+ if (rowKind(ls[t]) || ls[t].length > 200 || n === 2) break;
168
+ a = t; n++;
169
+ }
170
+ out.push([a + 1, end + 1]);
171
+ i = end; // past a block only, to its last line: the line that broke the run may open the next one
172
+ }
173
+ }
174
+ return out;
175
+ };
176
+ // The block whose matches score highest (rarer terms count more), then the densest, prints whole or as its range;
177
+ // one past BLOCK_MAX lines is left to its matching lines ("Read 1,300 lines" is no help).
178
+ const blockLines = (ls, all, score) => blocksOf(ls).filter(([a, b]) => b - a < BLOCK_MAX)
179
+ .map(([a, b]) => ({ a, b, s: all.filter(m => m.n >= a && m.n <= b).reduce((t, m) => t + score(m), 0) }))
180
+ .filter(x => x.s).sort((x, y) => y.s - x.s || y.s / (y.b - y.a) - x.s / (x.b - x.a) || x.a - y.a)
181
+ .slice(0, BLOCKS).map(({ a, b }) => {
182
+ // Never cut: a block that prints is whole, so a line past BLOCK_LINE makes it too long instead.
183
+ const body = ls.slice(a - 1, b).map((l, i) => [a + i, l]).filter(([, l]) => l.trim());
184
+ const fits = body.length <= BLOCK_LINES && body.every(([, l]) => l.length <= BLOCK_LINE)
185
+ && body.reduce((s, [, l]) => s + l.length, 0) <= BLOCK_CHARS;
186
+ return { a, b, out: fits ? [`(list or table, lines ${a}-${b}, shown whole)`, ...body.map(([n, l]) => `${n}: ${l}`)]
187
+ : [`(list or table, lines ${a}-${b}, ${b - a + 1} lines: too long to show; Read ${a}-${b})`], fits };
188
+ });
102
189
  // A page this short is printed whole: its answer can sit on a line no term matches (a Help page's "95%" line).
103
190
  // Index files by shape (Help's /apex/Help_*SiteMap* pages, *sitemap*.xml), on the path under the cache dir: a doc
104
191
  // page about sitemaps, or an --out dir named for them, is neither.
105
192
  const indexKind = rel => /(^|\/)apex\/Help_\w*SiteMapIndex|sitemap_index[^/]*\.xml$/i.test(rel) ? 'index'
106
193
  : /(^|\/)apex\/Help_\w*SiteMap|[^/]*sitemap[^/]*\.xml$/i.test(rel) ? 'map' : '';
194
+ // llms.txt and llms-<product>.txt, not a page whose cached name starts with llms (llms-overview.html.txt).
195
+ const isLlms = rel => /(^|\/)llms(-[\w-]+)?\.txt$/i.test(rel);
107
196
  const grep = (file, t) => {
108
- const text = readFileSync(file, 'utf8'), lines = text.split('\n'), kind = indexKind(slash(relative(out, file)));
109
- if (!file.endsWith('.links.txt') && lines.length <= SHORT_LINES && text.length <= SHORT_CHARS)
197
+ const text = readFileSync(file, 'utf8'), lines = text.split('\n'), rel = slash(relative(out, file)), kind = indexKind(rel);
198
+ const links = file.endsWith('.links.txt');
199
+ if (!links && lines.length <= SHORT_LINES && text.length <= SHORT_CHARS)
110
200
  return ['(short page, shown whole)', ...lines.slice(1, text.endsWith('\n') ? -1 : undefined).map((l, i) => `${i + 2}: ${l}`)]; // line 1 is the Source line
111
201
  // A sitemap index names page sitemaps, not topics, so no term can pick among them: print every <loc> line.
112
202
  if (kind === 'index') return matches(null, text, ['<loc>']).map(show);
113
203
  // A sitemap lists each article twice (<loc> and its alternates): keep the <loc> line and show 40 articles.
114
204
  const map = kind === 'map', all = matches(null, text, t).filter(m => !map || m.line.includes('<loc>'));
115
- const top = rank(all, map ? 40 : file.endsWith('.links.txt') ? MAX_LINKS : MAX_HITS);
116
- return [...top.map(show), ...more(all, top)];
205
+ // An llms.txt index is one long list of pages: its matching lines are the answer, not the list.
206
+ if (map || links || isLlms(rel)) { const top = rank(all, map ? 40 : links ? MAX_LINKS : MAX_HITS); return [...top.map(show), ...more(all, top)]; }
207
+ // Lines a printed block shows are not repeated; the rest are ranked as before, and both print in page order.
208
+ const score = scorer(all), blocks = all.length ? blockLines(lines, all, score) : [], rest = all.filter(m => !blocks.some(x => x.fits && m.n >= x.a && m.n <= x.b));
209
+ const top = rank(rest, MAX_HITS, score); // rarity over the whole page, not only the lines left
210
+ return [...top.map(m => [m.n, [show(m)]]), ...blocks.map(x => [x.a, x.out])].sort((x, y) => x[0] - y[0])
211
+ .flatMap(x => x[1]).concat(more(rest, top));
117
212
  };
118
213
 
119
214
  // --quote: the agent picks line numbers, the script copies the lines, so quotes are verbatim by construction.
@@ -165,7 +260,7 @@ if (args[0] === '--search') {
165
260
  const rel = slash(relative(out, f));
166
261
  // Sidecars and markers are not pages; a file another run pruned mid-walk is skipped.
167
262
  if (/\.(links\.txt|missing|tmp|fallback)$/.test(rel) || !statSync(f, { throwIfNoEntry: false })?.isFile()) continue;
168
- if (/(^|\/)llms[^/]*\.txt$/i.test(rel) || indexKind(rel)) continue; // indexes: --terms on the index already shows them
263
+ if (isLlms(rel) || indexKind(rel)) continue; // indexes: --terms on the index already shows them
169
264
  // At most PER_FILE lines a file, so ties across files cannot all come from the one that sorts first.
170
265
  try { const ms = matches(rel, readFileSync(f, 'utf8'), t); total += ms.length; all.push(...rank(ms, PER_FILE)); }
171
266
  catch (e) { if (e.code !== 'ENOENT') throw e; }
@@ -312,12 +407,10 @@ let ok = 0;
312
407
  // A saved page drops what is not doc text: image URLs (LDS signs S3 URLs of about 450 chars; the alt text stays) and
313
408
  // the padding in markdown table rows. Code blocks and wording are untouched.
314
409
  const lean = text => {
315
- let code = ''; // the open fence: closed only by the same character, at least as long (CommonMark)
316
- return text.split('\n').map(l => {
317
- const f = /^\s*(`{3,}|~{3,})/.exec(l)?.[1];
318
- if (f && !code) { code = f; return l; }
319
- if (f && f[0] === code[0] && f.length >= code.length) { code = ''; return l; }
320
- if (code) return l;
410
+ const ls = text.split('\n'), code = ls.map(() => false);
411
+ for (const [o, c] of fences(ls)) code.fill(true, o, c + 1);
412
+ return ls.map((l, i) => {
413
+ if (code[i]) return l;
321
414
  l = l.replace(/!\[([^\]]*)\]\(https?:\/\/[^)\s]+\)/g, '![$1]');
322
415
  return /^\s*\|/.test(l) ? l.replace(/ {2,}/g, ' ') : l;
323
416
  }).join('\n');
package/guard.mjs ADDED
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env node
2
+ // PreToolUse hook for the sf-docs agent: Bash may run fetch-docs.mjs and grep on files in this dir; Read may open them.
3
+ // It only denies (exit 2, reason on stderr), and denies anything its parser does not recognise; the rest falls through.
4
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const ROOT = realpathSync(dirname(fileURLToPath(import.meta.url)));
9
+ const SCRIPT = join(ROOT, 'fetch-docs.mjs');
10
+ const deny = why => {
11
+ console.error(`sf-docs guard: ${why}. Allowed: node "${SCRIPT.replaceAll('\\', '/')}" <args>, and grep on files under`
12
+ + ` "${ROOT.replaceAll('\\', '/')}" by absolute path, commands joined only by &&, grep with short options only (no -R).`
13
+ + ' No unquoted wildcards or #, pipes, cd, ;, variables, loops, redirects other than 2>&1, or other commands.');
14
+ process.exit(2);
15
+ };
16
+ // A crash exits 1, which Claude Code treats as a non-blocking error and runs the call: make it a deny.
17
+ process.on('uncaughtException', e => deny(`guard error: ${e.message}`));
18
+ // Judged by real path (of its nearest existing parent, for a file not there yet), so no symlink leads in or out.
19
+ const real = p => {
20
+ let head = p, tail = '';
21
+ while (!existsSync(head)) { if (dirname(head) === head) return p; tail = join(basename(head), tail); head = dirname(head); }
22
+ return join(realpathSync(head), tail);
23
+ };
24
+ const inside = p => { const r = relative(ROOT, real(p)); return !r.startsWith('..') && !isAbsolute(r); };
25
+
26
+ let input;
27
+ try { input = JSON.parse(readFileSync(0, 'utf8')); } catch { deny('hook input is not JSON'); }
28
+ const { tool_name: tool, tool_input: args = {} } = input;
29
+ if (tool === 'Read') {
30
+ if (typeof args.file_path !== 'string' || !isAbsolute(args.file_path) || !inside(resolve(args.file_path)))
31
+ deny(`Read is limited to files under the install dir, not ${args.file_path}`);
32
+ process.exit(0);
33
+ }
34
+ if (tool !== 'Bash') process.exit(0);
35
+ if (typeof args.command !== 'string') deny('no command');
36
+
37
+ // Split into commands of words on &&. Single quotes are literal; a $, backtick or any other shell syntax outside
38
+ // them is refused, so every word the shell will see is the word checked here.
39
+ const commands = [];
40
+ {
41
+ // Trim space, tab and newline only: JS trim() also strips \v, \r and NBSP, which the shell keeps as word text.
42
+ const src = args.command.replace(/^[ \t\n]+|[ \t\n]+$/g, '');
43
+ // No control character but tab, quoted or not: a NUL cuts a word, and a backslash-newline is dropped even in "...".
44
+ if (/[\0-\x08\x0a-\x1f\x7f-\x9f]/.test(src)) deny('a newline or control character');
45
+ let cmd = [], word = null;
46
+ const end = () => { if (word) cmd.push(word); word = null; };
47
+ const add = c => { word ??= { text: '' }; word.text += c; };
48
+ for (let i = 0; i < src.length; i++) {
49
+ const c = src[i];
50
+ if (c === "'") {
51
+ const j = src.indexOf("'", i + 1);
52
+ if (j < 0) deny('unclosed quote');
53
+ add(src.slice(i + 1, j)); i = j;
54
+ } else if (c === '"') {
55
+ add('');
56
+ for (i++; src[i] !== '"'; i++) {
57
+ if (i >= src.length) deny('unclosed quote');
58
+ if (src[i] === '$' || src[i] === '`') deny('variables and command substitution');
59
+ if (src[i] === '\\' && '"\\'.includes(src[i + 1])) i++;
60
+ word.text += src[i];
61
+ }
62
+ } else if (c === '\\') {
63
+ if (i + 1 >= src.length) deny('trailing backslash');
64
+ add(src[++i]);
65
+ } else if (c === ' ' || c === '\t') end();
66
+ else if (c === '&' && src[i + 1] === '&') { end(); commands.push(cmd); cmd = []; i++; }
67
+ else if (c === '2' && !word && src.startsWith('2>&1', i)
68
+ && ([' ', '\t', undefined].includes(src[i + 4]) || src.startsWith('&&', i + 4))) i += 3;
69
+ // A wildcard is checked as text but run as the files it matches (a symlink, or .. on bash before 5.2).
70
+ else if ('*?['.includes(c)) deny(`wildcard ${c}: name each file, or quote the pattern`);
71
+ else if ('$`'.includes(c)) deny('variables and command substitution');
72
+ else if (c === '|' || c === ';') deny(`${c}: join commands only with &&`);
73
+ // # anywhere: zsh's extendedglob makes x# a wildcard. = only starts a word in zsh's =cmd expansion.
74
+ else if ('&<>(){}!#~=^'.includes(c) && (!word || c !== '=')) deny(`shell syntax ${c}`);
75
+ else add(c);
76
+ }
77
+ end();
78
+ commands.push(cmd);
79
+ }
80
+
81
+ // grep short options only; VALUES take a value. -C only attached (-C3): BSD's -C value is optional. No -R: GNU -R
82
+ // follows every symlink it meets, GNU -r only those named on the line (checked here), BSD -r none by default.
83
+ const FLAGS = 'cEFhHiIlLnoqrsvwx0123456789', VALUES = 'eABmC';
84
+ for (const words of commands) {
85
+ const [cmd, ...rest] = words.map(w => w.text);
86
+ const path = w => {
87
+ if (!isAbsolute(w.text) || !inside(w.text)) deny(`${cmd} ${w.text}: not an absolute path under the install dir`);
88
+ };
89
+ if (cmd === 'node') {
90
+ if (!isAbsolute(rest[0]) || real(rest[0]) !== SCRIPT) deny(`node runs only fetch-docs.mjs, not ${rest[0]}`);
91
+ if (rest.some(a => a === '--out' || a.startsWith('--out='))) deny('--out moves the cache');
92
+ continue;
93
+ }
94
+ if (cmd !== 'grep') deny(`${cmd} is not allowed`);
95
+ // GNU grep reorders options, so an -e anywhere makes every positional a file: then none is taken as the pattern.
96
+ let pattern = !words.some(w => /^-[^-]*e/.test(w.text)), paths = 0;
97
+ for (let k = 1; k < words.length; k++) {
98
+ const t = words[k].text;
99
+ if (t === '--') deny('grep --: drop it, and give the pattern with -e');
100
+ if (t.startsWith('--')) deny(`grep ${t.split('=')[0]}: use the short option`); // the allowlist below denies it anyway
101
+ if (t.startsWith('-') && t.length > 1) {
102
+ for (let p = 1; p < t.length; p++) {
103
+ const c = t[p];
104
+ if (VALUES.includes(c)) {
105
+ if (p === t.length - 1) { if (c === 'C') deny('grep -C takes its number attached, like -C3'); if (++k >= words.length) deny(`-${c} needs a value`); }
106
+ break; // the rest of the word, or the next word, is the value
107
+ }
108
+ if (!FLAGS.includes(c)) deny(`grep -${c} is not allowed`);
109
+ }
110
+ continue;
111
+ }
112
+ if (pattern) { pattern = false; continue; }
113
+ path(words[k]);
114
+ paths++;
115
+ }
116
+ // grep with no file reads stdin, which nothing feeds here; -r with no file reads the shell's unknown cwd.
117
+ if (!paths) deny('grep needs a file under the install dir');
118
+ }
119
+ process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sf-docs-agent",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Claude Code subagent that answers from official Salesforce docs (developer, architect, help, admin, lightningdesignsystem.com) with grep-checked quotes",
6
6
  "devDependencies": {
@@ -12,7 +12,8 @@
12
12
  "files": [
13
13
  "bin",
14
14
  "agents",
15
- "fetch-docs.mjs"
15
+ "fetch-docs.mjs",
16
+ "guard.mjs"
16
17
  ],
17
18
  "repository": {
18
19
  "type": "git",