feat(discord): read-only tools for the Discord Sage through a Mosaic pi extension confined to declared roots (#1509)
A binding may declare `tools` with named roots. pi starts with --no-builtin-tools and the package's own extension, allowlisting list_dir, read_file and search. src/tools.mjs holds the rules: names not paths, per-segment lstat walk, one checked descriptor read that refuses symlinks, swaps, FIFOs, hard links and oversize files, credential shapes refusing the whole read, and a per-message call budget. The engine settles on agent_end and records tool calls in the turn record. Jason's rulings R1-R7 in the brief, section 7. rev-code-02 approved round 2 (comment 26276) on tree 43f0329b after four round 1 fixes. Suite 48/48, node tests 116. Not pushed. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
// Read-only tools for the Discord Sage, confined to declared roots. This is
|
||||
// the boundary that decides what a Discord user can make Sage read on this
|
||||
// host, so it is small, has no dependencies, and is tested without pi.
|
||||
//
|
||||
// The extension in ../extension/readonly-tools.mjs registers the three tools
|
||||
// with pi; every call comes here. Nothing here writes, spawns, or reads the
|
||||
// environment. A refusal is a normal result with ok=false and one fixed
|
||||
// reason; the model never sees a host path outside the root it asked for.
|
||||
//
|
||||
// Rules, applied before any read, in this order:
|
||||
// - the root must be one of the declared names; requests carry no
|
||||
// absolute paths, only a root name and a relative path
|
||||
// - no `..`, no empty segment, no segment that starts with a dot
|
||||
// (keeps .git, .env, .pi, .mosaic and every dotfile out with one rule)
|
||||
// - no symlink anywhere below the root (lstat at every step), and the
|
||||
// real path must sit under the root's real path
|
||||
// - only regular files are read, only directories are listed; a file with
|
||||
// more than one hard link is refused, since a link made under a root can
|
||||
// name a file outside it
|
||||
// - the read opens the checked file once, without following a final
|
||||
// symlink or blocking on a FIFO, confirms by device and inode that the
|
||||
// descriptor is the file the walk checked, and reads from that
|
||||
// descriptor only; a rename between the check and the open is refused
|
||||
// - a file over maxFileBytes, or with a NUL byte in its first 8 KiB, is
|
||||
// refused as too large or binary
|
||||
// - content that carries a credential shape refuses the whole read; a
|
||||
// second barrier behind the roots ruling, not the first
|
||||
// - at most maxCallsPerTurn calls between one agent_start and the end of
|
||||
// that run; past it every call is refused with a fixed reason
|
||||
|
||||
import { constants, lstatSync, openSync, fstatSync, readSync, closeSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { isAbsolute, join, sep } from "node:path";
|
||||
|
||||
export const TOOL_NAMES = Object.freeze(["list_dir", "read_file", "search"]);
|
||||
export const TOOLS_ENV = "MOSAIC_DISCORD_TOOLS";
|
||||
export const TOOL_DEFAULTS = Object.freeze({ maxFileBytes: 262144, maxCallsPerTurn: 8 });
|
||||
export const READ_DEFAULT_LINES = 200;
|
||||
export const READ_MAX_LINES = 400;
|
||||
export const LIST_MAX_ENTRIES = 200;
|
||||
export const SEARCH_MAX_HITS = 50;
|
||||
export const SEARCH_MAX_FILES = 2000;
|
||||
export const SEARCH_MAX_LINE_CHARS = 200;
|
||||
const BINARY_PROBE_BYTES = 8192;
|
||||
|
||||
export const REFUSAL = Object.freeze({
|
||||
UNKNOWN_ROOT: "unknown root: use one of the declared root names",
|
||||
BAD_PATH: "path must be relative, without '..', empty or dot-prefixed segments",
|
||||
SYMLINK: "symlinks are not followed",
|
||||
OUTSIDE: "path resolves outside the root",
|
||||
NOT_FOUND: "no such file or directory under that root",
|
||||
NOT_FILE: "not a regular file",
|
||||
NOT_DIR: "not a directory",
|
||||
TOO_LARGE: "file exceeds the size limit",
|
||||
BINARY: "file is not text",
|
||||
CREDENTIAL: "file content looks like a credential; the read is refused",
|
||||
BUDGET: "tool budget for this message is used up; answer with what you have",
|
||||
BAD_ARGS: "invalid arguments",
|
||||
UNREADABLE: "file cannot be read",
|
||||
CHANGED: "file or folder changed while it was being read",
|
||||
HARDLINK: "file has more than one hard link",
|
||||
});
|
||||
|
||||
// Shapes that must never reach Discord even if a file under a root holds
|
||||
// one. The first is the Discord bot token shape scripts/test-discord.sh
|
||||
// greps the package for; the rest are common credential assignments. The
|
||||
// third allows one scheme word between the separator and the value, which
|
||||
// is how an Authorization header is written.
|
||||
const CREDENTIAL_SHAPES = Object.freeze([
|
||||
/[A-Za-z0-9_-]{23,28}\.[A-Za-z0-9_-]{6,7}\.[A-Za-z0-9_-]{27,}/,
|
||||
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
||||
/(?:api[_-]?key|secret|password|passwd|token|authorization)["']?\s*[:=]\s*["']?(?:[A-Za-z]{2,16}\s+)?[A-Za-z0-9._\-+/=]{20,}/i,
|
||||
/\b(?:sk|ghp|gho|glpat|xox[abp])[-_][A-Za-z0-9_-]{16,}/,
|
||||
]);
|
||||
|
||||
export function looksLikeCredential(text) {
|
||||
return CREDENTIAL_SHAPES.some((re) => re.test(text));
|
||||
}
|
||||
|
||||
class Refusal extends Error {
|
||||
constructor(reason) {
|
||||
super(reason);
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
function isObject(v) {
|
||||
return v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
}
|
||||
|
||||
const ROOT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
|
||||
// Validate the configuration the engine hands the extension. Roots are
|
||||
// resolved once; each must be an absolute path to an existing directory that
|
||||
// is not a symlink, whose real path has no dot-prefixed segment. Returns a
|
||||
// frozen config with `real` set on every root.
|
||||
export function loadToolsConfig(raw, where = TOOLS_ENV) {
|
||||
if (!isObject(raw)) throw new Error(`${where}: not an object`);
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!["roots", "maxFileBytes", "maxCallsPerTurn"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
|
||||
}
|
||||
if (!Array.isArray(raw.roots) || raw.roots.length === 0) throw new Error(`${where}: roots must be a non-empty array`);
|
||||
const roots = raw.roots.map((r, i) => {
|
||||
const w = `${where}.roots[${i}]`;
|
||||
if (!isObject(r)) throw new Error(`${w}: not an object`);
|
||||
for (const k of Object.keys(r)) {
|
||||
if (!["name", "path"].includes(k)) throw new Error(`${w}: unknown key ${JSON.stringify(k)}`);
|
||||
}
|
||||
if (typeof r.name !== "string" || !ROOT_NAME.test(r.name)) throw new Error(`${w}: name must match ${ROOT_NAME}`);
|
||||
if (typeof r.path !== "string" || !isAbsolute(r.path) || r.path.includes("\0")) throw new Error(`${w}: path must be an absolute path`);
|
||||
if (r.path.split(sep).some((s) => s.startsWith(".") && s.length > 0)) throw new Error(`${w}: path has a dot-prefixed segment`);
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(r.path);
|
||||
} catch {
|
||||
throw new Error(`${w}: path does not exist: ${r.path}`);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new Error(`${w}: path is a symlink: ${r.path}`);
|
||||
if (!st.isDirectory()) throw new Error(`${w}: path is not a directory: ${r.path}`);
|
||||
const real = realpathSync(r.path);
|
||||
if (real.split(sep).some((s) => s.startsWith(".") && s.length > 0)) throw new Error(`${w}: real path has a dot-prefixed segment`);
|
||||
return Object.freeze({ name: r.name, path: r.path, real });
|
||||
});
|
||||
if (new Set(roots.map((r) => r.name)).size !== roots.length) throw new Error(`${where}: duplicate root name`);
|
||||
const merged = { ...TOOL_DEFAULTS, ...raw };
|
||||
const int = (k, min, max) => {
|
||||
const v = merged[k];
|
||||
if (!Number.isInteger(v) || v < min || v > max) throw new Error(`${where}: ${k} must be an integer in ${min}..${max}`);
|
||||
return v;
|
||||
};
|
||||
return Object.freeze({
|
||||
roots: Object.freeze(roots),
|
||||
maxFileBytes: int("maxFileBytes", 1024, 4 * 1024 * 1024),
|
||||
maxCallsPerTurn: int("maxCallsPerTurn", 1, 64),
|
||||
});
|
||||
}
|
||||
|
||||
// Split a relative path into segments, refusing anything that could leave
|
||||
// the root or reach a dotfile. "" means the root itself.
|
||||
function segments(path) {
|
||||
if (typeof path !== "string" || path.includes("\0") || isAbsolute(path) || path.startsWith("\\")) throw new Refusal(REFUSAL.BAD_PATH);
|
||||
const trimmed = path.replace(/\/+$/, "");
|
||||
if (trimmed === "") return [];
|
||||
const segs = trimmed.split(/[\\/]/);
|
||||
for (const s of segs) {
|
||||
if (s === "" || s === "." || s === ".." || s.startsWith(".")) throw new Refusal(REFUSAL.BAD_PATH);
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
// Walk from the root's real path one segment at a time with lstat, so a
|
||||
// symlink at any depth is refused before it is followed. Returns the
|
||||
// absolute path and its lstat.
|
||||
export function resolveUnder(root, path) {
|
||||
const segs = segments(path);
|
||||
let current = root.real;
|
||||
let st = lstatSync(current);
|
||||
for (const s of segs) {
|
||||
current = join(current, s);
|
||||
try {
|
||||
st = lstatSync(current);
|
||||
} catch {
|
||||
throw new Refusal(REFUSAL.NOT_FOUND);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new Refusal(REFUSAL.SYMLINK);
|
||||
}
|
||||
const real = realpathSync(current);
|
||||
if (real !== current || (real !== root.real && !real.startsWith(root.real + sep))) throw new Refusal(REFUSAL.OUTSIDE);
|
||||
return { abs: current, rel: segs.join("/"), st };
|
||||
}
|
||||
|
||||
// Open the path the walk validated, exactly once: no following a final
|
||||
// symlink (O_NOFOLLOW), no blocking on a FIFO (O_NONBLOCK). The descriptor
|
||||
// must be the file the walk checked (same device and inode), still a regular
|
||||
// file with one link; every byte comes from that descriptor, at most
|
||||
// maxBytes + 1 of them so a file that grew is refused too. A rename of the
|
||||
// file, or of any folder above it, between the walk and the open lands on a
|
||||
// different inode and is refused instead of followed.
|
||||
export function readVerified(abs, st, maxBytes) {
|
||||
if (typeof constants.O_NOFOLLOW !== "number" || typeof constants.O_NONBLOCK !== "number") {
|
||||
throw new Error("tools: this platform lacks O_NOFOLLOW or O_NONBLOCK; reads are not safe here");
|
||||
}
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(abs, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
} catch (err) {
|
||||
if (err.code === "ELOOP" || err.code === "EMLINK") throw new Refusal(REFUSAL.SYMLINK);
|
||||
if (err.code === "ENOENT" || err.code === "ENOTDIR") throw new Refusal(REFUSAL.CHANGED);
|
||||
throw new Refusal(REFUSAL.UNREADABLE);
|
||||
}
|
||||
try {
|
||||
let fst;
|
||||
try {
|
||||
fst = fstatSync(fd);
|
||||
} catch {
|
||||
throw new Refusal(REFUSAL.UNREADABLE);
|
||||
}
|
||||
if (!fst.isFile() || fst.dev !== st.dev || fst.ino !== st.ino) throw new Refusal(REFUSAL.CHANGED);
|
||||
if (fst.nlink > 1) throw new Refusal(REFUSAL.HARDLINK);
|
||||
const buf = Buffer.allocUnsafe(maxBytes + 1);
|
||||
let n = 0;
|
||||
for (;;) {
|
||||
let r;
|
||||
try {
|
||||
r = readSync(fd, buf, n, buf.length - n, n);
|
||||
} catch {
|
||||
throw new Refusal(REFUSAL.UNREADABLE);
|
||||
}
|
||||
if (r === 0) break;
|
||||
n += r;
|
||||
if (n > maxBytes) throw new Refusal(REFUSAL.TOO_LARGE);
|
||||
}
|
||||
return buf.subarray(0, n);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
// Read a text file under a root with every rule applied. Returns
|
||||
// {text, bytes} or throws a Refusal.
|
||||
function readText(root, path, config) {
|
||||
const { abs, st } = resolveUnder(root, path);
|
||||
if (!st.isFile()) throw new Refusal(REFUSAL.NOT_FILE);
|
||||
if (st.nlink > 1) throw new Refusal(REFUSAL.HARDLINK);
|
||||
if (st.size > config.maxFileBytes) throw new Refusal(REFUSAL.TOO_LARGE);
|
||||
const data = readVerified(abs, st, config.maxFileBytes);
|
||||
if (data.subarray(0, BINARY_PROBE_BYTES).includes(0)) throw new Refusal(REFUSAL.BINARY);
|
||||
const text = data.toString("utf8");
|
||||
if (looksLikeCredential(text)) throw new Refusal(REFUSAL.CREDENTIAL);
|
||||
return { text, bytes: data.length };
|
||||
}
|
||||
|
||||
function rootByName(config, name) {
|
||||
const root = typeof name === "string" ? config.roots.find((r) => r.name === name) : undefined;
|
||||
if (!root) throw new Refusal(REFUSAL.UNKNOWN_ROOT);
|
||||
return root;
|
||||
}
|
||||
|
||||
function optionalInt(v, name, { min, max, dflt }) {
|
||||
if (v === undefined || v === null) return dflt;
|
||||
if (!Number.isInteger(v) || v < min || v > max) throw new Refusal(`${REFUSAL.BAD_ARGS}: ${name} must be an integer in ${min}..${max}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
// --- the three tools, as pure functions over a config ---
|
||||
|
||||
export function listDir(config, { root: rootName, path = "" } = {}) {
|
||||
const root = rootByName(config, rootName);
|
||||
const { abs, rel, st } = resolveUnder(root, path);
|
||||
if (!st.isDirectory()) throw new Refusal(REFUSAL.NOT_DIR);
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(abs).filter((n) => !n.startsWith(".")).sort();
|
||||
} catch {
|
||||
throw new Refusal(REFUSAL.UNREADABLE);
|
||||
}
|
||||
// A listing shows names only, never content. Still, confirm the folder
|
||||
// just read is the one the walk checked; a swap seen here is refused.
|
||||
let after;
|
||||
try {
|
||||
after = lstatSync(abs);
|
||||
} catch {
|
||||
throw new Refusal(REFUSAL.CHANGED);
|
||||
}
|
||||
if (after.isSymbolicLink() || !after.isDirectory() || after.dev !== st.dev || after.ino !== st.ino) throw new Refusal(REFUSAL.CHANGED);
|
||||
const entries = [];
|
||||
for (const n of names) {
|
||||
if (entries.length >= LIST_MAX_ENTRIES) break;
|
||||
let est;
|
||||
try {
|
||||
est = lstatSync(join(abs, n));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (est.isSymbolicLink()) continue;
|
||||
if (est.isDirectory()) entries.push({ name: n, type: "dir" });
|
||||
else if (est.isFile()) entries.push({ name: n, type: "file", bytes: est.size });
|
||||
}
|
||||
return { root: root.name, path: rel, entries, truncated: names.length > LIST_MAX_ENTRIES };
|
||||
}
|
||||
|
||||
export function readFile(config, { root: rootName, path, offset, limit } = {}) {
|
||||
const root = rootByName(config, rootName);
|
||||
const from = optionalInt(offset, "offset", { min: 1, max: 10000000, dflt: 1 });
|
||||
const count = optionalInt(limit, "limit", { min: 1, max: READ_MAX_LINES, dflt: READ_DEFAULT_LINES });
|
||||
const { rel } = resolveUnder(root, path);
|
||||
const { text, bytes } = readText(root, path, config);
|
||||
const lines = text.split("\n");
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
const slice = lines.slice(from - 1, from - 1 + count);
|
||||
return { root: root.name, path: rel, bytes, totalLines: lines.length, offset: from, lines: slice };
|
||||
}
|
||||
|
||||
// A fixed-string, case-insensitive search over regular text files under a
|
||||
// subtree. Files that fail any read rule are skipped, not reported, so a
|
||||
// refused file cannot leak through a hit line.
|
||||
export function search(config, { root: rootName, text, path = "" } = {}) {
|
||||
const root = rootByName(config, rootName);
|
||||
if (typeof text !== "string" || text.trim().length === 0 || text.length > 200) throw new Refusal(`${REFUSAL.BAD_ARGS}: text must be 1..200 characters`);
|
||||
const needle = text.toLowerCase();
|
||||
const start = resolveUnder(root, path);
|
||||
const hits = [];
|
||||
let scanned = 0;
|
||||
let truncated = false;
|
||||
const visit = (abs, rel) => {
|
||||
if (truncated) return;
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(abs).filter((n) => !n.startsWith(".")).sort();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const n of names) {
|
||||
if (truncated) return;
|
||||
const childAbs = join(abs, n);
|
||||
const childRel = rel ? `${rel}/${n}` : n;
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(childAbs);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (st.isSymbolicLink()) continue;
|
||||
if (st.isDirectory()) {
|
||||
visit(childAbs, childRel);
|
||||
continue;
|
||||
}
|
||||
if (!st.isFile()) continue;
|
||||
if (scanned >= SEARCH_MAX_FILES) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
scanned += 1;
|
||||
let body;
|
||||
try {
|
||||
body = readText(root, childRel, config).text;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const lines = body.split("\n");
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
if (!lines[i].toLowerCase().includes(needle)) continue;
|
||||
if (hits.length >= SEARCH_MAX_HITS) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
hits.push({ path: childRel, line: i + 1, text: lines[i].trim().slice(0, SEARCH_MAX_LINE_CHARS) });
|
||||
}
|
||||
}
|
||||
};
|
||||
if (start.st.isDirectory()) visit(start.abs, start.rel);
|
||||
else if (start.st.isFile()) {
|
||||
scanned = 1;
|
||||
const body = readText(root, path, config).text;
|
||||
body.split("\n").forEach((l, i) => {
|
||||
if (hits.length < SEARCH_MAX_HITS && l.toLowerCase().includes(needle)) hits.push({ path: start.rel, line: i + 1, text: l.trim().slice(0, SEARCH_MAX_LINE_CHARS) });
|
||||
});
|
||||
} else throw new Refusal(REFUSAL.NOT_FILE);
|
||||
return { root: root.name, path: start.rel, text, hits, filesScanned: scanned, truncated };
|
||||
}
|
||||
|
||||
// --- the tool set the extension registers: budget plus rendering ---
|
||||
|
||||
const TOOL_FNS = Object.freeze({ list_dir: listDir, read_file: readFile, search });
|
||||
|
||||
function render(name, out) {
|
||||
if (name === "list_dir") {
|
||||
const head = `${out.root}/${out.path}`.replace(/\/$/, "");
|
||||
const body = out.entries.map((e) => (e.type === "dir" ? `${e.name}/` : `${e.name} (${e.bytes} bytes)`)).join("\n");
|
||||
return `${head}:\n${body || "(empty)"}${out.truncated ? `\n… listing cut at ${LIST_MAX_ENTRIES} entries` : ""}`;
|
||||
}
|
||||
if (name === "read_file") {
|
||||
const body = out.lines.map((l, i) => `${out.offset + i}: ${l}`).join("\n");
|
||||
const end = out.offset + out.lines.length - 1;
|
||||
return `${out.root}/${out.path} lines ${out.offset}-${end} of ${out.totalLines}\n${body}`;
|
||||
}
|
||||
const body = out.hits.map((h) => `${h.path}:${h.line}: ${h.text}`).join("\n");
|
||||
return `${out.hits.length} hit(s) for ${JSON.stringify(out.text)} under ${out.root}/${out.path || ""} (${out.filesScanned} files)${out.truncated ? ", cut short" : ""}\n${body || "(none)"}`;
|
||||
}
|
||||
|
||||
// A tool set with a per-run budget. `call(name, params)` never throws for a
|
||||
// policy refusal: it returns {ok, text, details}. Anything else that throws
|
||||
// is a bug and propagates.
|
||||
export function createToolSet(config) {
|
||||
let calls = 0;
|
||||
const call = (name, params) => {
|
||||
const fn = TOOL_FNS[name];
|
||||
if (!fn) throw new Error(`unknown tool ${name}`);
|
||||
const t0 = Date.now();
|
||||
const base = { tool: name, root: typeof params?.root === "string" ? params.root.slice(0, 64) : null, path: typeof params?.path === "string" ? params.path.slice(0, 512) : null };
|
||||
if (calls >= config.maxCallsPerTurn) {
|
||||
return { ok: false, text: `refused: ${REFUSAL.BUDGET}`, details: { ...base, ok: false, reason: REFUSAL.BUDGET, ms: 0 } };
|
||||
}
|
||||
calls += 1;
|
||||
try {
|
||||
const out = fn(config, params || {});
|
||||
const bytes = name === "read_file" ? out.bytes : undefined;
|
||||
return { ok: true, text: render(name, out), details: { ...base, ok: true, path: out.path, ...(bytes === undefined ? {} : { bytes }), ms: Date.now() - t0 } };
|
||||
} catch (err) {
|
||||
if (!(err instanceof Refusal)) throw err;
|
||||
return { ok: false, text: `refused: ${err.reason}`, details: { ...base, ok: false, reason: err.reason, ms: Date.now() - t0 } };
|
||||
}
|
||||
};
|
||||
return {
|
||||
call,
|
||||
resetBudget() {
|
||||
calls = 0;
|
||||
},
|
||||
get calls() {
|
||||
return calls;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const TOOL_DESCRIPTIONS = Object.freeze({
|
||||
list_dir: {
|
||||
label: "List directory",
|
||||
description: "List the files and folders directly under a path in one of the declared read-only roots. Paths are relative to the root; dotfiles and symlinks are not shown.",
|
||||
snippet: "list_dir lists a folder under a declared root",
|
||||
},
|
||||
read_file: {
|
||||
label: "Read file",
|
||||
description: `Read a window of lines from a text file under one of the declared read-only roots. Default ${READ_DEFAULT_LINES} lines from line 1, at most ${READ_MAX_LINES} per call; use offset to read further. Large, binary, hidden or credential-bearing files are refused.`,
|
||||
snippet: "read_file reads a text file under a declared root",
|
||||
},
|
||||
search: {
|
||||
label: "Search",
|
||||
description: `Find lines containing a fixed string (case-insensitive, no regular expressions) in text files under a declared read-only root, optionally within a subfolder. At most ${SEARCH_MAX_HITS} hits.`,
|
||||
snippet: "search finds a fixed string in files under a declared root",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user