@crewhaus/tool-secure 0.7.0 → 0.8.1

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/dist/lib/text.js CHANGED
@@ -79,6 +79,89 @@ export function maskValue(value, keep = 2) {
79
79
  const tail = value.slice(n - keep);
80
80
  return `${head}${"*".repeat(Math.min(n - 2 * keep, 12))}${tail}`;
81
81
  }
82
+ /**
83
+ * Spans, asked one question: does `[start, end)` overlap any of them?
84
+ *
85
+ * Overlap is `start < k.end && k.start < end`, the test `dedupeOverlaps` and
86
+ * `secretFindings` make, answered for any numbers, empty spans included.
87
+ * Among the spans that start before `end`, one overlaps exactly when the
88
+ * largest of their ends is past `start`. So the index keeps that largest end
89
+ * in a Fenwick tree with one slot per distinct start, and a question or an
90
+ * addition visits O(log n) slots. Checking every span kept so far made
91
+ * those two passes quadratic in the findings, on text a model or a web page
92
+ * supplies.
93
+ *
94
+ * Every start a span will be added with is named up front, because the
95
+ * slots are the distinct starts in order. `steps` counts the slots and
96
+ * probes the calls visit, so a test can hold the index to its bound.
97
+ */
98
+ export class SpanIndex {
99
+ /** Tree slots and binary-search probes visited so far. */
100
+ steps = 0;
101
+ /** The distinct starts, ascending. */
102
+ starts;
103
+ /** 1-based Fenwick tree of the largest end per prefix of `starts`. */
104
+ maxEnd;
105
+ constructor(starts) {
106
+ const sorted = new Float64Array(starts.length);
107
+ let n = 0;
108
+ // A NaN start never satisfies the test, so it never needs a slot.
109
+ for (let i = 0; i < starts.length; i++) {
110
+ const s = starts[i];
111
+ if (!Number.isNaN(s))
112
+ sorted[n++] = s;
113
+ }
114
+ sorted.subarray(0, n).sort();
115
+ let distinct = 0;
116
+ for (let i = 0; i < n; i++) {
117
+ const s = sorted[i];
118
+ if (distinct === 0 || s !== sorted[distinct - 1])
119
+ sorted[distinct++] = s;
120
+ }
121
+ this.starts = sorted.slice(0, distinct);
122
+ this.maxEnd = new Float64Array(distinct + 1).fill(Number.NEGATIVE_INFINITY);
123
+ }
124
+ /** How many distinct starts are below `bound`; none are below NaN. */
125
+ below(bound) {
126
+ let lo = 0;
127
+ let hi = this.starts.length;
128
+ while (lo < hi) {
129
+ this.steps++;
130
+ const mid = (lo + hi) >>> 1;
131
+ if (this.starts[mid] < bound)
132
+ lo = mid + 1;
133
+ else
134
+ hi = mid;
135
+ }
136
+ return lo;
137
+ }
138
+ /** Whether some span added so far has `k.start < end && start < k.end`. */
139
+ overlaps(start, end) {
140
+ let most = Number.NEGATIVE_INFINITY;
141
+ for (let i = this.below(end); i > 0; i -= i & -i) {
142
+ this.steps++;
143
+ const e = this.maxEnd[i];
144
+ if (e > most)
145
+ most = e;
146
+ }
147
+ return most > start;
148
+ }
149
+ /** Add `[start, end)`; `start` must be one the index was built with. */
150
+ add(start, end) {
151
+ // A NaN on either side never satisfies the test, so it is never found.
152
+ if (Number.isNaN(start) || Number.isNaN(end))
153
+ return;
154
+ const at = this.below(start);
155
+ if (at >= this.starts.length || this.starts[at] !== start) {
156
+ throw new Error(`SpanIndex: start ${start} was not named when the index was built`);
157
+ }
158
+ for (let i = at + 1; i < this.maxEnd.length; i += i & -i) {
159
+ this.steps++;
160
+ if (end > this.maxEnd[i])
161
+ this.maxEnd[i] = end;
162
+ }
163
+ }
164
+ }
82
165
  /**
83
166
  * Drop findings swallowed by a stronger one.
84
167
  *
@@ -87,6 +170,10 @@ export function maskValue(value, keep = 2) {
87
170
  * survivor is chosen by confidence, then by length, then by earliest start,
88
171
  * then by rule name — a total order, so the same input always yields the
89
172
  * same set.
173
+ *
174
+ * Each candidate, in that order, is kept unless it overlaps one kept before
175
+ * it. A `SpanIndex` answers that, so the pass is O(k log k) in the findings
176
+ * rather than a check against every finding kept so far.
90
177
  */
91
178
  export function dedupeOverlaps(findings) {
92
179
  const ranked = [...findings].sort((a, b) => {
@@ -100,11 +187,13 @@ export function dedupeOverlaps(findings) {
100
187
  return a.start - b.start;
101
188
  return compareStrings(a.rule, b.rule);
102
189
  });
190
+ const index = new SpanIndex(ranked.map((f) => f.start));
103
191
  const kept = [];
104
192
  for (const candidate of ranked) {
105
- const clashes = kept.some((k) => candidate.start < k.end && k.start < candidate.end);
106
- if (!clashes)
107
- kept.push(candidate);
193
+ if (index.overlaps(candidate.start, candidate.end))
194
+ continue;
195
+ kept.push(candidate);
196
+ index.add(candidate.start, candidate.end);
108
197
  }
109
198
  return sortFindings(kept);
110
199
  }
@@ -123,11 +212,54 @@ export function withPositions(text, raw) {
123
212
  /**
124
213
  * Replace each span with the string `replace` returns for it.
125
214
  *
126
- * Spans must not overlap (`dedupeOverlaps` guarantees that); they are
127
- * applied right to left so earlier offsets stay valid.
215
+ * Spans must not overlap (`dedupeOverlaps` guarantees that). `replace` is
216
+ * called right to left, as it always has been, and the output is the text
217
+ * between the spans joined with their replacements: one copy of the text.
218
+ * Rebuilding the whole string for every span was O(spans × text), and a
219
+ * redaction pass over megabytes of findings-dense text has hundreds of
220
+ * thousands of spans. Spans that overlap, sit out of order or outside the
221
+ * text, or have fractional offsets keep the old right-to-left rebuild, which
222
+ * gives the same result on spans that do none of those things.
128
223
  */
129
224
  export function applySpans(text, findings, replace) {
130
225
  const ordered = sortFindings(findings);
226
+ if (!disjointWithin(ordered, text.length))
227
+ return rebuildRightToLeft(text, ordered, replace);
228
+ const replacements = new Array(ordered.length);
229
+ for (let i = ordered.length - 1; i >= 0; i--) {
230
+ const f = ordered[i];
231
+ if (f)
232
+ replacements[i] = replace(f);
233
+ }
234
+ const parts = [];
235
+ let at = 0;
236
+ for (let i = 0; i < ordered.length; i++) {
237
+ const f = ordered[i];
238
+ if (!f)
239
+ continue;
240
+ parts.push(text.slice(at, f.start), replacements[i]);
241
+ at = f.end;
242
+ }
243
+ parts.push(text.slice(at));
244
+ return parts.join("");
245
+ }
246
+ /** Whether sorted spans are whole offsets, in order, apart, and inside the text. */
247
+ function disjointWithin(ordered, length) {
248
+ let at = 0;
249
+ for (let i = 0; i < ordered.length; i++) {
250
+ const f = ordered[i];
251
+ if (!f)
252
+ continue;
253
+ if (!Number.isInteger(f.start) || !Number.isInteger(f.end))
254
+ return false;
255
+ if (f.start < at || f.end < f.start || f.end > length)
256
+ return false;
257
+ at = f.end;
258
+ }
259
+ return true;
260
+ }
261
+ /** The original rebuild, kept for spans `disjointWithin` refuses. */
262
+ function rebuildRightToLeft(text, ordered, replace) {
131
263
  let out = text;
132
264
  for (let i = ordered.length - 1; i >= 0; i--) {
133
265
  const f = ordered[i];
@@ -173,6 +305,43 @@ export function* matchAll(text, pattern) {
173
305
  re.lastIndex += 1;
174
306
  }
175
307
  }
308
+ /**
309
+ * `matchAll(text, pattern)`'s matches, in order, with the pattern tried
310
+ * (sticky) only where `canStart` says a match may begin, and, after a try
311
+ * that fails at `at`, not again before `failedTo(text, at)`.
312
+ *
313
+ * For a pattern that opens with an unbounded run (an email's local part, a
314
+ * word before a comma, the whitespace after a line start), a global search
315
+ * that fails at one start moves one character right and scans the same run
316
+ * again: quadratic in the run's length. Every later start inside such a run
317
+ * shares the run's end and reads the same text after it, so it fails too;
318
+ * `failedTo` names where that stops being true, and the matches are the
319
+ * global pattern's, start for start. After a match the next try is at its
320
+ * end (one further for an empty match), as `lastIndex` would be.
321
+ */
322
+ export function* matchAllSkipping(text, pattern, canStart, failedTo) {
323
+ let flags = pattern.flags.replace("g", "");
324
+ if (!flags.includes("d"))
325
+ flags = `${flags}d`;
326
+ if (!flags.includes("y"))
327
+ flags = `${flags}y`;
328
+ const re = new RegExp(pattern.source, flags);
329
+ let at = 0;
330
+ while (at <= text.length) {
331
+ if (!canStart(text, at)) {
332
+ at++;
333
+ continue;
334
+ }
335
+ re.lastIndex = at;
336
+ const m = re.exec(text);
337
+ if (m !== null) {
338
+ yield { index: at, match: m };
339
+ at += Math.max(1, m[0].length);
340
+ continue;
341
+ }
342
+ at = Math.max(at + 1, failedTo(text, at));
343
+ }
344
+ }
176
345
  /**
177
346
  * The absolute `[start, end)` of one capture group of a match produced by
178
347
  * `matchAll`, or `undefined` when the group did not participate.
package/dist/paths.d.ts CHANGED
@@ -1,10 +1,29 @@
1
1
  import { CrewhausError } from "@crewhaus/errors";
2
- /** Raised when a caller-supplied path resolves outside the workspace root. */
2
+ /**
3
+ * The longest path this resolves, in UTF-16 code units; a longer one is
4
+ * refused before it is walked. Nothing that long can be opened or created:
5
+ * PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
6
+ * 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
7
+ * UTF-16 one. The limit is twice that because the part of a path that exists
8
+ * is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
9
+ * own: no spelling of the place reaches it, not even one through a symlink
10
+ * that shortens the part that exists.
11
+ */
12
+ export declare const MAX_PATH_CHARS: number;
13
+ /**
14
+ * Raised when a caller-supplied path resolves outside the workspace root, or
15
+ * is longer than any path can be.
16
+ */
3
17
  export declare class ToolPermissionError extends CrewhausError {
4
18
  readonly name = "ToolPermissionError";
5
19
  readonly toolName: string;
6
20
  readonly path: string;
7
- constructor(toolName: string, attemptedPath: string);
21
+ /**
22
+ * Why, when it is not that the location escapes the workspace root: a path
23
+ * longer than any path can be. A caller that rewords the refusal says this.
24
+ */
25
+ readonly reason: string | undefined;
26
+ constructor(toolName: string, attemptedPath: string, reason?: string);
8
27
  }
9
28
  /**
10
29
  * A validated path, in both forms the tools need.
package/dist/paths.js CHANGED
@@ -11,15 +11,45 @@
11
11
  import { lstatSync, readlinkSync, realpathSync } from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import { CrewhausError } from "@crewhaus/errors";
14
- /** Raised when a caller-supplied path resolves outside the workspace root. */
14
+ /**
15
+ * The longest path this resolves, in UTF-16 code units; a longer one is
16
+ * refused before it is walked. Nothing that long can be opened or created:
17
+ * PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
18
+ * 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
19
+ * UTF-16 one. The limit is twice that because the part of a path that exists
20
+ * is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
21
+ * own: no spelling of the place reaches it, not even one through a symlink
22
+ * that shortens the part that exists.
23
+ */
24
+ export const MAX_PATH_CHARS = 2 * (process.platform === "darwin" ? 1_024 : process.platform === "win32" ? 32_767 : 4_096);
25
+ /** A path as a refusal quotes it: whole, unless it is past {@link MAX_PATH_CHARS}. */
26
+ function shownPath(given) {
27
+ return given.length > MAX_PATH_CHARS ? `${given.slice(0, 64)}…` : given;
28
+ }
29
+ /** Why a path past {@link MAX_PATH_CHARS} is refused: its length and the limit. */
30
+ function tooLong(abs) {
31
+ return `it is ${abs.length} characters once resolved, over the ${MAX_PATH_CHARS}-character limit: no path that long can be opened or created`;
32
+ }
33
+ /**
34
+ * Raised when a caller-supplied path resolves outside the workspace root, or
35
+ * is longer than any path can be.
36
+ */
15
37
  export class ToolPermissionError extends CrewhausError {
16
38
  name = "ToolPermissionError";
17
39
  toolName;
18
40
  path;
19
- constructor(toolName, attemptedPath) {
20
- super("tool", `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`);
41
+ /**
42
+ * Why, when it is not that the location escapes the workspace root: a path
43
+ * longer than any path can be. A caller that rewords the refusal says this.
44
+ */
45
+ reason;
46
+ constructor(toolName, attemptedPath, reason) {
47
+ super("tool", reason === undefined
48
+ ? `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`
49
+ : `tool "${toolName}" rejected path "${shownPath(attemptedPath)}": ${reason}`);
21
50
  this.toolName = toolName;
22
51
  this.path = attemptedPath;
52
+ this.reason = reason;
23
53
  }
24
54
  }
25
55
  /** The workspace root, resolved. Every path is contained within it. */
@@ -67,15 +97,7 @@ function nameExists(p) {
67
97
  function resolveLocation(target, depth = 0) {
68
98
  if (depth > 40)
69
99
  throw new Error(`symlink chain at "${target}" is too long to resolve`);
70
- let probe = target;
71
- const tail = [];
72
- while (!nameExists(probe)) {
73
- tail.unshift(path.basename(probe));
74
- const parent = path.dirname(probe);
75
- if (parent === probe)
76
- break; // reached the filesystem root
77
- probe = parent;
78
- }
100
+ const { probe, tail } = deepestName(target);
79
101
  let probeReal;
80
102
  try {
81
103
  probeReal = realpathSync(probe);
@@ -97,7 +119,41 @@ function resolveLocation(target, depth = 0) {
97
119
  const base = realpathSync(path.dirname(probe));
98
120
  probeReal = resolveLocation(path.resolve(base, link), depth + 1);
99
121
  }
100
- return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
122
+ // One string, not one argument per name: Bun's `path.join` keeps only
123
+ // (argument count mod 65,536) of its arguments.
124
+ return tail.length > 0 ? path.join(probeReal, tail) : probeReal;
125
+ }
126
+ /**
127
+ * `target`, an absolute path, split where its deepest prefix that exists as
128
+ * a NAME ends: that prefix, and the names below it, which do not exist, as
129
+ * one string ("" when `target` itself exists). This is `path.dirname` and
130
+ * `path.basename` worked by index on `target` itself: handing them each
131
+ * shorter prefix in turn copied the rest of the path once per missing name,
132
+ * the square of a count the caller picks.
133
+ */
134
+ function deepestName(target) {
135
+ const top = path.parse(target).root.length;
136
+ const isSep = (i) => target[i] === "/" || target[i] === path.sep;
137
+ let probe = target;
138
+ let start = target.length;
139
+ let stop = -1;
140
+ while (!nameExists(probe)) {
141
+ // `path.basename(probe)` is `target.slice(start, end)`, and
142
+ // `path.dirname(probe)` is `target.slice(0, up)`.
143
+ let end = probe.length;
144
+ while (end > top && isSep(end - 1))
145
+ end--;
146
+ if (stop < 0)
147
+ stop = end;
148
+ start = end;
149
+ while (start > top && !isSep(start - 1))
150
+ start--;
151
+ const up = start > top ? start - 1 : top;
152
+ if (up === probe.length)
153
+ break; // reached the filesystem root
154
+ probe = target.slice(0, up);
155
+ }
156
+ return { probe, tail: stop < 0 ? "" : target.slice(start, stop) };
101
157
  }
102
158
  export function resolveSafe(toolName, rel, root = workspaceRoot()) {
103
159
  const rootResolved = path.resolve(root);
@@ -107,6 +163,9 @@ export function resolveSafe(toolName, rel, root = workspaceRoot()) {
107
163
  if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
108
164
  throw new ToolPermissionError(toolName, rel);
109
165
  }
166
+ // Longer than any path can be: refused before the walk below reads it.
167
+ if (abs.length > MAX_PATH_CHARS)
168
+ throw new ToolPermissionError(toolName, rel, tooLong(abs));
110
169
  let real;
111
170
  try {
112
171
  const rootReal = realpathSync(rootResolved);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-secure",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "Deterministic security tools: PII detection and redaction, secret scanning, prompt-injection heuristics, policy checks and evidence hashing",
6
6
  "main": "dist/index.js",
@@ -8,19 +8,22 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
12
13
  }
13
14
  },
14
15
  "scripts": {
15
16
  "test": "bun test src"
16
17
  },
17
18
  "dependencies": {
18
- "@crewhaus/errors": "0.7.0",
19
- "@crewhaus/tool-builder": "0.7.0",
20
- "@crewhaus/tool-catalog": "0.7.0",
19
+ "@crewhaus/errors": "0.8.1",
20
+ "@crewhaus/tool-builder": "0.8.1",
21
+ "@crewhaus/tool-catalog": "0.8.1",
22
+ "@crewhaus/tool-safety": "0.8.1",
21
23
  "zod": "^3.23.8"
22
24
  },
23
25
  "devDependencies": {
26
+ "@crewhaus/tool-categories": "workspace:*",
24
27
  "@crewhaus/tool-executor": "workspace:*"
25
28
  },
26
29
  "license": "Apache-2.0",
@@ -41,5 +44,8 @@
41
44
  "publishConfig": {
42
45
  "access": "public"
43
46
  },
44
- "files": ["dist", "README.md", "LICENSE", "NOTICE"]
47
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"],
48
+ "engines": {
49
+ "bun": ">=1.3.14"
50
+ }
45
51
  }