@taurgis/bonsai 3.2.0 → 4.0.0
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 +9 -6
- package/agents/agents/salesforce-docs-researcher.agent.md +17 -4
- package/agents/instructions/salesforce-research.instructions.md +2 -1
- package/bin/cli.mjs +13 -18
- package/dist/base-command.d.ts +59 -12
- package/dist/base-command.js +179 -51
- package/dist/commands/config/base.js +12 -2
- package/dist/commands/config/get.js +8 -12
- package/dist/commands/config/index.js +2 -2
- package/dist/commands/config/list.js +11 -17
- package/dist/commands/config/set.d.ts +2 -1
- package/dist/commands/config/set.js +35 -28
- package/dist/commands/config/unset.d.ts +2 -1
- package/dist/commands/config/unset.js +26 -25
- package/dist/commands/fetch.d.ts +7 -15
- package/dist/commands/fetch.js +61 -401
- package/dist/commands/import.d.ts +4 -9
- package/dist/commands/import.js +56 -284
- package/dist/commands/inspect.d.ts +7 -1
- package/dist/commands/inspect.js +112 -52
- package/dist/commands/list.d.ts +18 -3
- package/dist/commands/list.js +121 -46
- package/dist/commands/prune.d.ts +6 -1
- package/dist/commands/prune.js +89 -78
- package/dist/commands/status.d.ts +3 -2
- package/dist/commands/status.js +62 -79
- package/dist/hooks/command-not-found/suggest.d.ts +2 -1
- package/dist/hooks/command-not-found/suggest.js +78 -41
- package/dist/lib/argv.d.ts +29 -3
- package/dist/lib/argv.js +147 -54
- package/dist/lib/batch.d.ts +41 -0
- package/dist/lib/batch.js +39 -0
- package/dist/lib/cache-view.d.ts +21 -0
- package/dist/lib/cache-view.js +30 -0
- package/dist/lib/cli-emit.d.ts +28 -0
- package/dist/lib/cli-emit.js +44 -0
- package/dist/lib/cli-error-policy.d.ts +57 -0
- package/dist/lib/cli-error-policy.js +74 -0
- package/dist/lib/cli-flag-manifest.d.ts +12 -0
- package/dist/lib/cli-flag-manifest.js +15 -0
- package/dist/lib/cli-presentation.d.ts +41 -0
- package/dist/lib/cli-presentation.js +53 -0
- package/dist/lib/cli-result-types.d.ts +112 -0
- package/dist/lib/cli-result-types.js +2 -0
- package/dist/lib/config/index.d.ts +23 -5
- package/dist/lib/config/index.js +60 -5
- package/dist/lib/config/io.d.ts +9 -0
- package/dist/lib/config/io.js +71 -21
- package/dist/lib/config/schema.d.ts +2 -0
- package/dist/lib/config/schema.js +7 -0
- package/dist/lib/config-persist.d.ts +28 -0
- package/dist/lib/config-persist.js +29 -0
- package/dist/lib/envelope.d.ts +41 -3
- package/dist/lib/envelope.js +154 -4
- package/dist/lib/help-preflight.d.ts +5 -8
- package/dist/lib/help-preflight.js +13 -32
- package/dist/lib/json-meta.d.ts +0 -2
- package/dist/lib/json-meta.js +21 -25
- package/dist/lib/limit-flag.d.ts +11 -1
- package/dist/lib/limit-flag.js +19 -6
- package/dist/lib/parse-error-ux.d.ts +5 -0
- package/dist/lib/parse-error-ux.js +73 -0
- package/dist/lib/prune-flags.d.ts +30 -0
- package/dist/lib/prune-flags.js +86 -0
- package/dist/lib/research/artifact-index.d.ts +11 -2
- package/dist/lib/research/artifact-index.js +14 -5
- package/dist/lib/research/artifact.js +20 -2
- package/dist/lib/research/browser.d.ts +53 -2
- package/dist/lib/research/browser.js +202 -58
- package/dist/lib/research/capture.d.ts +24 -2
- package/dist/lib/research/capture.js +24 -4
- package/dist/lib/research/cli-io.d.ts +40 -0
- package/dist/lib/research/cli-io.js +27 -0
- package/dist/lib/research/compress.d.ts +7 -0
- package/dist/lib/research/compress.js +7 -3
- package/dist/lib/research/docs/capabilities.d.ts +15 -0
- package/dist/lib/research/docs/capabilities.js +5 -0
- package/dist/lib/research/docs/clean-dom.d.ts +2 -0
- package/dist/lib/research/docs/clean-dom.js +2 -0
- package/dist/lib/research/docs/code-blocks.d.ts +2 -0
- package/dist/lib/research/docs/code-blocks.js +2 -0
- package/dist/lib/research/docs/detect.d.ts +4 -0
- package/dist/lib/research/docs/detect.js +4 -0
- package/dist/lib/research/docs/machine-readable.d.ts +18 -0
- package/dist/lib/research/docs/machine-readable.js +10 -0
- package/dist/lib/research/docs/markdown-source.d.ts +20 -0
- package/dist/lib/research/docs/markdown-source.js +19 -2
- package/dist/lib/research/docs/quality-gates.d.ts +5 -0
- package/dist/lib/research/docs/quality-gates.js +4 -0
- package/dist/lib/research/docs/rsc.d.ts +9 -2
- package/dist/lib/research/docs/rsc.js +5 -2
- package/dist/lib/research/docs/section-artifacts.d.ts +12 -0
- package/dist/lib/research/docs/section-artifacts.js +12 -0
- package/dist/lib/research/docs/sections.d.ts +18 -0
- package/dist/lib/research/docs/sections.js +17 -1
- package/dist/lib/research/docs/source-map.d.ts +28 -0
- package/dist/lib/research/docs/source-map.js +27 -0
- package/dist/lib/research/docs/validate.d.ts +35 -2
- package/dist/lib/research/docs/validate.js +47 -2
- package/dist/lib/research/extract.d.ts +16 -0
- package/dist/lib/research/extract.js +12 -1
- package/dist/lib/research/fetch-command-service.d.ts +36 -0
- package/dist/lib/research/fetch-command-service.js +367 -0
- package/dist/lib/research/fetch-result.d.ts +114 -0
- package/dist/lib/research/fetch-result.js +212 -0
- package/dist/lib/research/fetcher.d.ts +46 -8
- package/dist/lib/research/fetcher.js +61 -31
- package/dist/lib/research/freshness.d.ts +54 -11
- package/dist/lib/research/freshness.js +91 -31
- package/dist/lib/research/import-command-service.d.ts +56 -0
- package/dist/lib/research/import-command-service.js +402 -0
- package/dist/lib/research/keywords.js +5 -1
- package/dist/lib/research/markdown.d.ts +12 -1
- package/dist/lib/research/markdown.js +13 -5
- package/dist/lib/research/metadata-filters.d.ts +18 -0
- package/dist/lib/research/metadata-filters.js +37 -0
- package/dist/lib/research/metadata-flags.d.ts +34 -0
- package/dist/lib/research/metadata-flags.js +45 -0
- package/dist/lib/research/persist-artifact.d.ts +23 -0
- package/dist/lib/research/persist-artifact.js +27 -0
- package/dist/lib/research/prompt-injection.d.ts +9 -0
- package/dist/lib/research/prompt-injection.js +59 -14
- package/dist/lib/research/proxy.d.ts +14 -8
- package/dist/lib/research/proxy.js +14 -10
- package/dist/lib/research/resolve-target.d.ts +2 -0
- package/dist/lib/research/resolve-target.js +1 -1
- package/dist/lib/research/revalidate.d.ts +49 -20
- package/dist/lib/research/revalidate.js +80 -15
- package/dist/lib/research/schema.d.ts +7 -0
- package/dist/lib/research/schema.js +2 -0
- package/dist/lib/research/secret-scan.d.ts +6 -0
- package/dist/lib/research/secret-scan.js +8 -0
- package/dist/lib/research/secure-write.d.ts +4 -0
- package/dist/lib/research/secure-write.js +31 -2
- package/dist/lib/research/storage.d.ts +9 -8
- package/dist/lib/research/storage.js +34 -28
- package/dist/lib/research/summarize.d.ts +15 -0
- package/dist/lib/research/summarize.js +11 -0
- package/dist/lib/research/url.d.ts +9 -6
- package/dist/lib/research/url.js +33 -21
- package/dist/lib/text.d.ts +19 -8
- package/dist/lib/text.js +32 -9
- package/dist/lib/value-flags.d.ts +14 -0
- package/dist/lib/value-flags.js +28 -0
- package/dist/sites/coveo.d.ts +10 -0
- package/dist/sites/coveo.js +10 -0
- package/dist/sites/index.d.ts +16 -0
- package/dist/sites/index.js +16 -4
- package/dist/sites/markdown-twin.d.ts +31 -0
- package/dist/sites/markdown-twin.js +56 -0
- package/dist/sites/salesforce/fetch-page.d.ts +8 -2
- package/dist/sites/salesforce/fetch-page.js +14 -3
- package/dist/sites/salesforce/help-tooling-markdown.d.ts +30 -0
- package/dist/sites/salesforce/help-tooling-markdown.js +74 -0
- package/dist/sites/salesforce/index.d.ts +1 -0
- package/dist/sites/salesforce/index.js +1 -0
- package/dist/sites/salesforce-developer/fetch-page.d.ts +6 -1
- package/dist/sites/salesforce-developer/fetch-page.js +6 -1
- package/dist/sites/salesforce-developer/index.d.ts +1 -0
- package/dist/sites/salesforce-developer/index.js +1 -0
- package/dist/sites/salesforce-developer/route-markdown.d.ts +18 -6
- package/dist/sites/salesforce-developer/route-markdown.js +29 -67
- package/dist/sites/salesforce-doc-fetch.d.ts +39 -5
- package/dist/sites/salesforce-doc-fetch.js +52 -7
- package/dist/sites/salesforce-dom-probe.d.ts +20 -2
- package/dist/sites/salesforce-dom-probe.js +20 -2
- package/dist/sites/types.d.ts +14 -8
- package/dist/sites/types.js +3 -0
- package/oclif.manifest.json +216 -102
- package/package.json +20 -7
- package/agents/agents/official-docs-researcher.agent.md +0 -87
- package/agents/instructions/web-research.instructions.md +0 -44
- package/agents/skills/web-research/SKILL.md +0 -118
|
@@ -4,16 +4,37 @@ import { applySiteFetchProvenance } from '../../sites/types.js';
|
|
|
4
4
|
import { fetchStaticHtml } from './fetcher.js';
|
|
5
5
|
import { extractHtmlContent } from './extract.js';
|
|
6
6
|
import { writeArtifact } from './storage.js';
|
|
7
|
-
import {
|
|
7
|
+
import { persistArtifact } from './persist-artifact.js';
|
|
8
|
+
import { evaluateFreshness, resolveFreshnessPolicy } from './freshness.js';
|
|
8
9
|
import { buildCompressed } from './compress.js';
|
|
9
10
|
import { applyAutoTags } from './keywords.js';
|
|
10
11
|
import { estimateTokens } from './token-estimate.js';
|
|
11
12
|
import { fetchRenderedHtml } from './browser.js';
|
|
12
13
|
import { looksLikeErrorPage } from './docs/validate.js';
|
|
13
|
-
function
|
|
14
|
+
function persistRevalidated(dataDir, key, artifact, secure) {
|
|
15
|
+
if (secure) {
|
|
16
|
+
const result = persistArtifact({
|
|
17
|
+
roots: secure.roots,
|
|
18
|
+
cacheKey: key,
|
|
19
|
+
artifact,
|
|
20
|
+
dryRun: Boolean(secure.dryRun),
|
|
21
|
+
kind: 'fetch',
|
|
22
|
+
scratchDir: secure.scratchDir,
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
storageDir: result.dataDir,
|
|
26
|
+
redirectedToGlobal: result.redirected,
|
|
27
|
+
redirectWarning: result.redirectWarning,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
writeArtifact(dataDir, key, artifact);
|
|
31
|
+
return { storageDir: dataDir, redirectedToGlobal: false, redirectWarning: null };
|
|
32
|
+
}
|
|
33
|
+
function buildMetadata(input) {
|
|
34
|
+
const { url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, compressed, } = input;
|
|
14
35
|
const contentHash = createHash('sha256').update(extraction.detailedMarkdown).digest('hex');
|
|
15
36
|
const staleAfterTime = new Date(currentTime);
|
|
16
|
-
const { freshWindowMs } =
|
|
37
|
+
const { freshWindowMs } = resolveFreshnessPolicy(tier, ttl);
|
|
17
38
|
staleAfterTime.setTime(staleAfterTime.getTime() + freshWindowMs);
|
|
18
39
|
return {
|
|
19
40
|
schema_version: 1,
|
|
@@ -57,7 +78,8 @@ function buildMetadata(url, normalizedUrl, cacheKey, fetchResult, extraction, ti
|
|
|
57
78
|
// Builds a compact error marker instead of caching a full error page. Subsequent lookups serve this
|
|
58
79
|
// tiny marker (a handful of tokens) instead of re-fetching, and revalidation re-checks it when stale
|
|
59
80
|
// so a transient failure recovers into a real artifact.
|
|
60
|
-
function buildErrorArtifact(
|
|
81
|
+
function buildErrorArtifact(input) {
|
|
82
|
+
const { url, normalizedUrl, cacheKey, fetchResult, tier, ttl, currentTime, reason } = input;
|
|
61
83
|
const marker = `Error: ${url} could not be cached — ${reason}. ` +
|
|
62
84
|
'The page returned an error, so its content was not stored; it will be re-fetched when this entry goes stale.';
|
|
63
85
|
const extraction = {
|
|
@@ -66,7 +88,17 @@ function buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl
|
|
|
66
88
|
confidence: 'low',
|
|
67
89
|
qualityNotes: [`error: ${reason}`],
|
|
68
90
|
};
|
|
69
|
-
const metadata = buildMetadata(
|
|
91
|
+
const metadata = buildMetadata({
|
|
92
|
+
url,
|
|
93
|
+
normalizedUrl,
|
|
94
|
+
cacheKey,
|
|
95
|
+
fetchResult,
|
|
96
|
+
extraction,
|
|
97
|
+
tier,
|
|
98
|
+
ttl,
|
|
99
|
+
currentTime,
|
|
100
|
+
compressed: marker,
|
|
101
|
+
});
|
|
70
102
|
metadata.extraction_status = 'failed';
|
|
71
103
|
metadata.extraction_confidence = null;
|
|
72
104
|
return {
|
|
@@ -78,17 +110,40 @@ function buildErrorArtifact(url, normalizedUrl, cacheKey, fetchResult, tier, ttl
|
|
|
78
110
|
};
|
|
79
111
|
}
|
|
80
112
|
/**
|
|
81
|
-
*
|
|
113
|
+
* Construct a ResearchArtifact from a fresh HTML fetch (or error-page marker).
|
|
114
|
+
*
|
|
115
|
+
* @param input - URL, fetch result, extraction, tier/TTL, clock, and summary level.
|
|
116
|
+
* @returns A ready-to-persist research artifact.
|
|
82
117
|
*/
|
|
83
|
-
export function createArtifactFromFetch(
|
|
118
|
+
export function createArtifactFromFetch(input) {
|
|
119
|
+
const { url, normalizedUrl, cacheKey, fetchResult, extraction, tier, ttl, currentTime, summaryLevel, } = input;
|
|
84
120
|
// A managed platform / SPA can return HTTP 200 but render only a "not found" / "something went
|
|
85
121
|
// wrong" shell. Cache a compact marker for those instead of the full error markdown, so repeat
|
|
86
122
|
// lookups cost a few tokens and revalidation still re-checks the page when the entry goes stale.
|
|
87
123
|
if (looksLikeErrorPage(extraction.detailedMarkdown)) {
|
|
88
|
-
return buildErrorArtifact(
|
|
124
|
+
return buildErrorArtifact({
|
|
125
|
+
url,
|
|
126
|
+
normalizedUrl,
|
|
127
|
+
cacheKey,
|
|
128
|
+
fetchResult,
|
|
129
|
+
tier,
|
|
130
|
+
ttl,
|
|
131
|
+
currentTime,
|
|
132
|
+
reason: 'page reported an error or was not found',
|
|
133
|
+
});
|
|
89
134
|
}
|
|
90
135
|
const compressed = buildCompressed(extraction.detailedMarkdown, summaryLevel);
|
|
91
|
-
const metadata = buildMetadata(
|
|
136
|
+
const metadata = buildMetadata({
|
|
137
|
+
url,
|
|
138
|
+
normalizedUrl,
|
|
139
|
+
cacheKey,
|
|
140
|
+
fetchResult,
|
|
141
|
+
extraction,
|
|
142
|
+
tier,
|
|
143
|
+
ttl,
|
|
144
|
+
currentTime,
|
|
145
|
+
compressed,
|
|
146
|
+
});
|
|
92
147
|
return {
|
|
93
148
|
metadata,
|
|
94
149
|
summary: extraction.title,
|
|
@@ -113,7 +168,17 @@ function preserveUserMetadata(source, target, rendered) {
|
|
|
113
168
|
// returns the "refreshed" result. Shared by both refresh paths (conditional-request fallthrough
|
|
114
169
|
// and site-module re-fetch), which differ only in how fetchResult/extraction are obtained.
|
|
115
170
|
function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options, siteFetch) {
|
|
116
|
-
const refreshed = createArtifactFromFetch(
|
|
171
|
+
const refreshed = createArtifactFromFetch({
|
|
172
|
+
url: meta.source_url,
|
|
173
|
+
normalizedUrl: meta.normalized_url,
|
|
174
|
+
cacheKey: meta.cache_key,
|
|
175
|
+
fetchResult,
|
|
176
|
+
extraction,
|
|
177
|
+
tier: meta.tier,
|
|
178
|
+
ttl: options.ttlOverride || meta.ttl,
|
|
179
|
+
currentTime,
|
|
180
|
+
summaryLevel: options.summaryLevel,
|
|
181
|
+
});
|
|
117
182
|
preserveUserMetadata(meta, refreshed, options.rendered);
|
|
118
183
|
// A site-module refresh reports how it actually captured this time; that overrides the
|
|
119
184
|
// carried-over provenance, so a withdrawn .md twin can't leave a stale source_doc_url behind.
|
|
@@ -122,8 +187,8 @@ function persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, curren
|
|
|
122
187
|
// Back-fill keyword tags when the carried-over set is empty (e.g. an artifact first cached before
|
|
123
188
|
// auto-tagging, or one originally stored without tags), so refreshing keeps it searchable.
|
|
124
189
|
applyAutoTags(refreshed);
|
|
125
|
-
|
|
126
|
-
return { status: 'refreshed', artifact: refreshed };
|
|
190
|
+
const write = persistRevalidated(dataDir, meta.cache_key, refreshed, options.secure);
|
|
191
|
+
return { status: 'refreshed', artifact: refreshed, ...write };
|
|
127
192
|
}
|
|
128
193
|
async function handleRevalidateResponse(dataDir, existing, fetchResult, currentTime, options) {
|
|
129
194
|
const meta = existing.metadata;
|
|
@@ -136,11 +201,11 @@ async function handleRevalidateResponse(dataDir, existing, fetchResult, currentT
|
|
|
136
201
|
},
|
|
137
202
|
};
|
|
138
203
|
const staleAfterTime = new Date(currentTime);
|
|
139
|
-
const { freshWindowMs } =
|
|
204
|
+
const { freshWindowMs } = resolveFreshnessPolicy(meta.tier, options.ttlOverride || meta.ttl);
|
|
140
205
|
staleAfterTime.setTime(staleAfterTime.getTime() + freshWindowMs);
|
|
141
206
|
updated.metadata.stale_after = staleAfterTime.toISOString();
|
|
142
|
-
|
|
143
|
-
return { status: 'revalidated', artifact: updated };
|
|
207
|
+
const write = persistRevalidated(dataDir, meta.cache_key, updated, options.secure);
|
|
208
|
+
return { status: 'revalidated', artifact: updated, ...write };
|
|
144
209
|
}
|
|
145
210
|
const extraction = extractHtmlContent(fetchResult.content, fetchResult.finalUrl);
|
|
146
211
|
return persistRefreshedArtifact(dataDir, meta, fetchResult, extraction, currentTime, options);
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
+
/** Token counts for the two stored representations of a research artifact. */
|
|
1
2
|
export interface TokenEstimate {
|
|
2
3
|
compressed: number | null;
|
|
3
4
|
detailed: number | null;
|
|
4
5
|
}
|
|
6
|
+
/** All recognized capture methods. Command flag option lists derive from this constant. */
|
|
5
7
|
export declare const CAPTURE_METHODS: readonly ["static_fetch", "browser_fallback", "agent_supplied", "route_markdown", "github_source"];
|
|
8
|
+
/** How a research artifact's content was obtained. */
|
|
6
9
|
export type CaptureMethod = (typeof CAPTURE_METHODS)[number];
|
|
10
|
+
/** All recognized artifact types. `index` = navigation hub or llms.txt; `section` = heading-level child of a page. */
|
|
7
11
|
export declare const ARTIFACT_TYPES: readonly ["source", "research_note", "index", "section"];
|
|
12
|
+
/** Classification of a research artifact's role. */
|
|
8
13
|
export type ArtifactType = (typeof ARTIFACT_TYPES)[number];
|
|
14
|
+
/** Full metadata block stored alongside every cached research artifact. */
|
|
9
15
|
export interface ResearchArtifactMetadata {
|
|
10
16
|
schema_version: number;
|
|
11
17
|
artifact_type: ArtifactType;
|
|
@@ -41,6 +47,7 @@ export interface ResearchArtifactMetadata {
|
|
|
41
47
|
section_anchor: string | null;
|
|
42
48
|
section_heading_path: string | null;
|
|
43
49
|
}
|
|
50
|
+
/** A complete research artifact: metadata, summary, compressed and detailed Markdown, and provenance. */
|
|
44
51
|
export interface ResearchArtifact {
|
|
45
52
|
metadata: ResearchArtifactMetadata;
|
|
46
53
|
summary: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Single source of truth for the capture-method enum. Command flag `options` lists derive from this
|
|
2
2
|
// so a new method can never drift out of sync with the filters that are supposed to expose it.
|
|
3
|
+
/** All recognized capture methods. Command flag option lists derive from this constant. */
|
|
3
4
|
export const CAPTURE_METHODS = [
|
|
4
5
|
'static_fetch',
|
|
5
6
|
'browser_fallback',
|
|
@@ -9,4 +10,5 @@ export const CAPTURE_METHODS = [
|
|
|
9
10
|
];
|
|
10
11
|
// Single source of truth for the artifact-type enum. `index` = navigation/hub or llms.txt site
|
|
11
12
|
// index; `section` = a heading-level child of a page. Command flag `options` derive from this.
|
|
13
|
+
/** All recognized artifact types. `index` = navigation hub or llms.txt; `section` = heading-level child of a page. */
|
|
12
14
|
export const ARTIFACT_TYPES = ['source', 'research_note', 'index', 'section'];
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { ResearchArtifact } from './schema.js';
|
|
2
2
|
/** Return the label of the first secret pattern found in `text`, or null if none match. */
|
|
3
3
|
export declare function detectSecret(text: string): string | null;
|
|
4
|
+
/**
|
|
5
|
+
* Strip every secret-pattern match from `text`, replacing each with a single space. Matches the
|
|
6
|
+
* original (case-preserved, unsplit) text, so callers must run this before any lowercasing or
|
|
7
|
+
* tokenization step that would otherwise hide a pattern like `AKIA…` or `ghp_…` from detection.
|
|
8
|
+
*/
|
|
9
|
+
export declare function stripSecrets(text: string): string;
|
|
4
10
|
/** Scan every content field of an artifact. Returns the detected secret's label, or null. */
|
|
5
11
|
export declare function scanArtifactForSecret(artifact: ResearchArtifact): string | null;
|
|
@@ -30,6 +30,14 @@ export function detectSecret(text) {
|
|
|
30
30
|
}
|
|
31
31
|
return null;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Strip every secret-pattern match from `text`, replacing each with a single space. Matches the
|
|
35
|
+
* original (case-preserved, unsplit) text, so callers must run this before any lowercasing or
|
|
36
|
+
* tokenization step that would otherwise hide a pattern like `AKIA…` or `ghp_…` from detection.
|
|
37
|
+
*/
|
|
38
|
+
export function stripSecrets(text) {
|
|
39
|
+
return SECRET_PATTERNS.reduce((result, { re }) => result.replace(new RegExp(re.source, `${re.flags}g`), ' '), text);
|
|
40
|
+
}
|
|
33
41
|
/** Scan every content field of an artifact. Returns the detected secret's label, or null. */
|
|
34
42
|
export function scanArtifactForSecret(artifact) {
|
|
35
43
|
const haystack = [artifact.summary, artifact.compressed, artifact.detailed, artifact.provenance]
|
|
@@ -16,6 +16,10 @@ export interface SecureWriteResult {
|
|
|
16
16
|
* `dryRun` still runs the secret scan and reports the real would-be destination, but skips the
|
|
17
17
|
* actual `writeArtifact` persist — so a read-only preview never gives a falsely reassuring answer
|
|
18
18
|
* about where secret-bearing content would land.
|
|
19
|
+
*
|
|
20
|
+
* On redirect, any existing project copy is archived and removed so lookup cannot keep serving the
|
|
21
|
+
* project shadow after the secret-bearing content moved to global (revalidation parity with
|
|
22
|
+
* first-time project writes — see #90 / AUDIT_71_FINAL).
|
|
19
23
|
*/
|
|
20
24
|
export declare function writeArtifactSecurely(roots: StoreRoots, key: string, artifact: ResearchArtifact, options?: {
|
|
21
25
|
dryRun?: boolean;
|
|
@@ -1,5 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { writeArtifact, getArtifactPath } from './storage.js';
|
|
2
4
|
import { scanArtifactForSecret } from './secret-scan.js';
|
|
5
|
+
/**
|
|
6
|
+
* Archive then remove an active project artifact so a redirected global copy is not shadowed by
|
|
7
|
+
* project→global lookup order. Best-effort: a failed archive/unlink never undoes the global write.
|
|
8
|
+
*
|
|
9
|
+
* @see https://nodejs.org/api/fs.html#fsunlinksyncpath
|
|
10
|
+
*/
|
|
11
|
+
function clearProjectArtifactAfterRedirect(projectRoot, key) {
|
|
12
|
+
const path = getArtifactPath(projectRoot, key);
|
|
13
|
+
if (!existsSync(path))
|
|
14
|
+
return;
|
|
15
|
+
try {
|
|
16
|
+
const content = readFileSync(path, 'utf-8');
|
|
17
|
+
const dir = join(projectRoot, 'research');
|
|
18
|
+
writeFileSync(join(dir, `${key}.superseded.${Date.now()}.md`), content, 'utf-8');
|
|
19
|
+
unlinkSync(path);
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
console.warn(`Warning: failed to clear project artifact after secret redirect: ${err.message}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
3
25
|
/**
|
|
4
26
|
* Write an artifact to the configured write root, except when it contains a secret and the
|
|
5
27
|
* target is a (potentially committed) project cache — those are redirected to the global cache.
|
|
@@ -8,12 +30,19 @@ import { scanArtifactForSecret } from './secret-scan.js';
|
|
|
8
30
|
* `dryRun` still runs the secret scan and reports the real would-be destination, but skips the
|
|
9
31
|
* actual `writeArtifact` persist — so a read-only preview never gives a falsely reassuring answer
|
|
10
32
|
* about where secret-bearing content would land.
|
|
33
|
+
*
|
|
34
|
+
* On redirect, any existing project copy is archived and removed so lookup cannot keep serving the
|
|
35
|
+
* project shadow after the secret-bearing content moved to global (revalidation parity with
|
|
36
|
+
* first-time project writes — see #90 / AUDIT_71_FINAL).
|
|
11
37
|
*/
|
|
12
38
|
export function writeArtifactSecurely(roots, key, artifact, options = {}) {
|
|
13
39
|
const isProjectWrite = roots.writeRoot !== roots.globalRoot;
|
|
14
40
|
const secretLabel = isProjectWrite ? scanArtifactForSecret(artifact) : null;
|
|
15
41
|
const dataDir = secretLabel ? roots.globalRoot : roots.writeRoot;
|
|
16
|
-
if (!options.dryRun)
|
|
42
|
+
if (!options.dryRun) {
|
|
17
43
|
writeArtifact(dataDir, key, artifact);
|
|
44
|
+
if (secretLabel)
|
|
45
|
+
clearProjectArtifactAfterRedirect(roots.writeRoot, key);
|
|
46
|
+
}
|
|
18
47
|
return { dataDir, redirected: Boolean(secretLabel), secretLabel };
|
|
19
48
|
}
|
|
@@ -13,13 +13,10 @@ export declare function getArtifactPath(dataDir: string, key: string): string;
|
|
|
13
13
|
/**
|
|
14
14
|
* Scans the research storage directory, reading metadata of all valid markdown files
|
|
15
15
|
* to find the active artifact for the given cache key with the newest validated_at timestamp.
|
|
16
|
-
* If a corrupt artifact is found, it is skipped and archived as corrupt
|
|
16
|
+
* If a corrupt artifact is found, it is skipped and archived as corrupt — unless `readOnly` is set,
|
|
17
|
+
* in which case it is only reported, never renamed on disk.
|
|
17
18
|
*/
|
|
18
|
-
export declare function findArtifact(dataDir: string, key: string): ResearchArtifact | null;
|
|
19
|
-
/**
|
|
20
|
-
* Checks if a cached artifact exists on disk for a given cache key.
|
|
21
|
-
*/
|
|
22
|
-
export declare function hasArtifact(dataDir: string, key: string): boolean;
|
|
19
|
+
export declare function findArtifact(dataDir: string, key: string, readOnly?: boolean): ResearchArtifact | null;
|
|
23
20
|
/**
|
|
24
21
|
* Writes a ResearchArtifact to disk, creating the research storage folder if needed.
|
|
25
22
|
* Writes to a temporary file first, then atomically renames it.
|
|
@@ -42,11 +39,15 @@ export interface LocatedArtifact {
|
|
|
42
39
|
* returning the first match along with where it lives. Implements the project→global
|
|
43
40
|
* read fallback: a missing project entry still resolves against the global cache.
|
|
44
41
|
*/
|
|
45
|
-
export declare function locateArtifact(dataDirs: string[], key: string): LocatedArtifact | null;
|
|
42
|
+
export declare function locateArtifact(dataDirs: string[], key: string, readOnly?: boolean): LocatedArtifact | null;
|
|
46
43
|
/**
|
|
47
44
|
* Multi-root variant of {@link scanCacheDir}. Scans the `research/` dir of each data dir in
|
|
48
45
|
* order and deduplicates by cache key so an entry present in an earlier root (project) shadows
|
|
49
46
|
* the same key in a later root (global) — even when the callback filters it out. Composes
|
|
50
47
|
* scanCacheDir so the file-format and read/parse rules stay defined in exactly one place.
|
|
48
|
+
*
|
|
49
|
+
* Pass `persistIndex: false` under read-only/plan mode so listing never creates `.search-index.json`.
|
|
51
50
|
*/
|
|
52
|
-
export declare function scanCacheDirs<T>(dataDirs: string[], fn: (artifact: ResearchArtifact, filePath: string) => T | null
|
|
51
|
+
export declare function scanCacheDirs<T>(dataDirs: string[], fn: (artifact: ResearchArtifact, filePath: string) => T | null, options?: {
|
|
52
|
+
persistIndex?: boolean;
|
|
53
|
+
}): T[];
|
|
@@ -18,7 +18,14 @@ export function scanCacheDir(dir, fn) {
|
|
|
18
18
|
continue;
|
|
19
19
|
try {
|
|
20
20
|
const content = readFileSync(join(dir, file), 'utf-8');
|
|
21
|
-
const
|
|
21
|
+
const artifact = parseArtifact(content);
|
|
22
|
+
// parseArtifact defaults unrecognized frontmatter to a valid-looking shell (cache_key: '',
|
|
23
|
+
// status: 'active'), so a foreign or otherwise-garbled *.md file with `---`/`---` fences never
|
|
24
|
+
// throws here — it would otherwise surface as a phantom entry with no usable cache key. Every
|
|
25
|
+
// artifact Bonsai itself writes always has one, so an empty cache_key means "not ours."
|
|
26
|
+
if (!artifact.metadata.cache_key)
|
|
27
|
+
continue;
|
|
28
|
+
const result = fn(artifact, join(dir, file));
|
|
22
29
|
if (result !== null)
|
|
23
30
|
results.push(result);
|
|
24
31
|
}
|
|
@@ -36,8 +43,9 @@ export function getArtifactPath(dataDir, key) {
|
|
|
36
43
|
}
|
|
37
44
|
return join(dataDir, 'research', `${key}.md`);
|
|
38
45
|
}
|
|
39
|
-
function processFileForLookup(file, dir, key) {
|
|
40
|
-
|
|
46
|
+
function processFileForLookup(file, dir, key, readOnly) {
|
|
47
|
+
// Same file filter as scanCacheDir — superseded/corrupt/tmp archives must not resolve as active.
|
|
48
|
+
if (!isResearchFile(file)) {
|
|
41
49
|
return null;
|
|
42
50
|
}
|
|
43
51
|
const filePath = join(dir, file);
|
|
@@ -50,13 +58,17 @@ function processFileForLookup(file, dir, key) {
|
|
|
50
58
|
}
|
|
51
59
|
catch (err) {
|
|
52
60
|
console.warn(`Warning: Corrupt research artifact found at "${filePath}": ${err.message}`);
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
61
|
+
// Archiving renames the file on disk — a write a read-only/plan-mode lookup (e.g. `status`,
|
|
62
|
+
// `inspect --read-only`) must never perform, even incidentally while diagnosing a corrupt entry.
|
|
63
|
+
if (!readOnly) {
|
|
64
|
+
try {
|
|
65
|
+
const corruptPath = `${filePath}.corrupt.${Date.now()}`;
|
|
66
|
+
renameSync(filePath, corruptPath);
|
|
67
|
+
console.warn(`Archived corrupt artifact to "${corruptPath}"`);
|
|
68
|
+
}
|
|
69
|
+
catch (archiveErr) {
|
|
70
|
+
console.error(`Failed to archive corrupt artifact: ${archiveErr.message}`);
|
|
71
|
+
}
|
|
60
72
|
}
|
|
61
73
|
}
|
|
62
74
|
return null;
|
|
@@ -64,9 +76,10 @@ function processFileForLookup(file, dir, key) {
|
|
|
64
76
|
/**
|
|
65
77
|
* Scans the research storage directory, reading metadata of all valid markdown files
|
|
66
78
|
* to find the active artifact for the given cache key with the newest validated_at timestamp.
|
|
67
|
-
* If a corrupt artifact is found, it is skipped and archived as corrupt
|
|
79
|
+
* If a corrupt artifact is found, it is skipped and archived as corrupt — unless `readOnly` is set,
|
|
80
|
+
* in which case it is only reported, never renamed on disk.
|
|
68
81
|
*/
|
|
69
|
-
export function findArtifact(dataDir, key) {
|
|
82
|
+
export function findArtifact(dataDir, key, readOnly = false) {
|
|
70
83
|
const dir = join(dataDir, 'research');
|
|
71
84
|
if (!existsSync(dir)) {
|
|
72
85
|
return null;
|
|
@@ -75,7 +88,7 @@ export function findArtifact(dataDir, key) {
|
|
|
75
88
|
let bestValidatedAt = -1;
|
|
76
89
|
const files = readdirSync(dir);
|
|
77
90
|
for (const file of files) {
|
|
78
|
-
const artifact = processFileForLookup(file, dir, key);
|
|
91
|
+
const artifact = processFileForLookup(file, dir, key, readOnly);
|
|
79
92
|
if (!artifact) {
|
|
80
93
|
continue;
|
|
81
94
|
}
|
|
@@ -89,17 +102,6 @@ export function findArtifact(dataDir, key) {
|
|
|
89
102
|
}
|
|
90
103
|
return bestArtifact;
|
|
91
104
|
}
|
|
92
|
-
/**
|
|
93
|
-
* Checks if a cached artifact exists on disk for a given cache key.
|
|
94
|
-
*/
|
|
95
|
-
export function hasArtifact(dataDir, key) {
|
|
96
|
-
try {
|
|
97
|
-
return findArtifact(dataDir, key) !== null;
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
return false;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
105
|
/**
|
|
104
106
|
* Writes a ResearchArtifact to disk, creating the research storage folder if needed.
|
|
105
107
|
* Writes to a temporary file first, then atomically renames it.
|
|
@@ -148,9 +150,9 @@ export function readArtifact(dataDir, key) {
|
|
|
148
150
|
* returning the first match along with where it lives. Implements the project→global
|
|
149
151
|
* read fallback: a missing project entry still resolves against the global cache.
|
|
150
152
|
*/
|
|
151
|
-
export function locateArtifact(dataDirs, key) {
|
|
153
|
+
export function locateArtifact(dataDirs, key, readOnly = false) {
|
|
152
154
|
for (const dataDir of dataDirs) {
|
|
153
|
-
const artifact = findArtifact(dataDir, key);
|
|
155
|
+
const artifact = findArtifact(dataDir, key, readOnly);
|
|
154
156
|
if (artifact) {
|
|
155
157
|
return { artifact, dataDir, path: getArtifactPath(dataDir, key) };
|
|
156
158
|
}
|
|
@@ -162,12 +164,16 @@ export function locateArtifact(dataDirs, key) {
|
|
|
162
164
|
* order and deduplicates by cache key so an entry present in an earlier root (project) shadows
|
|
163
165
|
* the same key in a later root (global) — even when the callback filters it out. Composes
|
|
164
166
|
* scanCacheDir so the file-format and read/parse rules stay defined in exactly one place.
|
|
167
|
+
*
|
|
168
|
+
* Pass `persistIndex: false` under read-only/plan mode so listing never creates `.search-index.json`.
|
|
165
169
|
*/
|
|
166
|
-
export function scanCacheDirs(dataDirs, fn) {
|
|
170
|
+
export function scanCacheDirs(dataDirs, fn, options = {}) {
|
|
167
171
|
const seen = new Set();
|
|
168
172
|
return dataDirs.flatMap((dataDir) => {
|
|
169
173
|
const researchDir = join(dataDir, 'research');
|
|
170
|
-
const searchable = loadIndexedArtifactsForDir(researchDir
|
|
174
|
+
const searchable = loadIndexedArtifactsForDir(researchDir, {
|
|
175
|
+
persist: options.persistIndex,
|
|
176
|
+
});
|
|
171
177
|
return searchable
|
|
172
178
|
.map(({ artifact, filePath }) => {
|
|
173
179
|
const key = artifact.metadata.cache_key;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { SummaryLevel } from '../config/schema.js';
|
|
2
|
+
/** Re-export of the configured summary aggressiveness level (`conservative` | `balanced` | `aggressive`). */
|
|
2
3
|
export type { SummaryLevel };
|
|
4
|
+
/**
|
|
5
|
+
* Options for `summarizeMarkdown`. All fields are optional; omitted values use the documented defaults.
|
|
6
|
+
*/
|
|
3
7
|
export interface SummarizeOptions {
|
|
4
8
|
/** Aggressiveness of prose reduction. Default 'conservative'. */
|
|
5
9
|
level?: SummaryLevel;
|
|
@@ -8,10 +12,21 @@ export interface SummarizeOptions {
|
|
|
8
12
|
/** Prose paragraphs with fewer sentences than this are kept whole. Default 2. */
|
|
9
13
|
minSentences?: number;
|
|
10
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Splits a prose paragraph into individual sentences. Avoids false boundaries inside inline code,
|
|
17
|
+
* after known abbreviations, and before digits.
|
|
18
|
+
*
|
|
19
|
+
* @param paragraph - A single prose paragraph (no Markdown structure like headings or lists).
|
|
20
|
+
* @returns Array of sentence strings in document order.
|
|
21
|
+
*/
|
|
11
22
|
export declare function splitSentences(paragraph: string): string[];
|
|
12
23
|
/**
|
|
13
24
|
* Extractively shortens the prose paragraphs of a Markdown document while preserving headings,
|
|
14
25
|
* fenced code blocks, tables, and lists verbatim. Returns the input unchanged when it is below
|
|
15
26
|
* `minTokens` or contains no prose to condense. Deterministic for identical input + options.
|
|
27
|
+
*
|
|
28
|
+
* @param markdown - Markdown document to summarize.
|
|
29
|
+
* @param options - Summarization options (level, minTokens, minSentences).
|
|
30
|
+
* @returns Summarized Markdown, or the original when no summarization is needed.
|
|
16
31
|
*/
|
|
17
32
|
export declare function summarizeMarkdown(markdown: string, options?: SummarizeOptions): string;
|
|
@@ -166,6 +166,13 @@ function shouldSkipBoundary(masked, i, end) {
|
|
|
166
166
|
// Splits a single prose paragraph into sentences, avoiding splits inside inline code, after known
|
|
167
167
|
// abbreviations or single-letter initials, and before digits. Exported for unit testing because
|
|
168
168
|
// sentence boundaries are not observable once kept sentences are rejoined.
|
|
169
|
+
/**
|
|
170
|
+
* Splits a prose paragraph into individual sentences. Avoids false boundaries inside inline code,
|
|
171
|
+
* after known abbreviations, and before digits.
|
|
172
|
+
*
|
|
173
|
+
* @param paragraph - A single prose paragraph (no Markdown structure like headings or lists).
|
|
174
|
+
* @returns Array of sentence strings in document order.
|
|
175
|
+
*/
|
|
169
176
|
export function splitSentences(paragraph) {
|
|
170
177
|
const { masked, restore } = maskInlineCode(paragraph);
|
|
171
178
|
const sentences = [];
|
|
@@ -381,6 +388,10 @@ function reassemble(segments, sentences, kept) {
|
|
|
381
388
|
* Extractively shortens the prose paragraphs of a Markdown document while preserving headings,
|
|
382
389
|
* fenced code blocks, tables, and lists verbatim. Returns the input unchanged when it is below
|
|
383
390
|
* `minTokens` or contains no prose to condense. Deterministic for identical input + options.
|
|
391
|
+
*
|
|
392
|
+
* @param markdown - Markdown document to summarize.
|
|
393
|
+
* @param options - Summarization options (level, minTokens, minSentences).
|
|
394
|
+
* @returns Summarized Markdown, or the original when no summarization is needed.
|
|
384
395
|
*/
|
|
385
396
|
export function summarizeMarkdown(markdown, options = {}) {
|
|
386
397
|
const level = options.level ?? 'conservative';
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Checks if a literal IPv4 address is in a safe range.
|
|
3
|
-
* Rejects loopback, private RFC1918, and link-local
|
|
3
|
+
* Rejects "this network", loopback, private RFC1918, shared address space (CGNAT), and link-local
|
|
4
|
+
* ranges — the non-globally-reachable blocks in the IANA IPv4 special-purpose registry
|
|
5
|
+
* (https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml).
|
|
4
6
|
*/
|
|
5
7
|
export declare function isSafeIpv4(ip: string): boolean;
|
|
6
8
|
/**
|
|
7
9
|
* Checks if a literal IPv6 address is in a safe range.
|
|
8
|
-
* Rejects loopback, unspecified, link-local, and mapped unsafe IPv4 ranges.
|
|
10
|
+
* Rejects loopback, unspecified, link-local, unique-local, and mapped unsafe IPv4 ranges.
|
|
9
11
|
*/
|
|
10
12
|
export declare function isSafeIpv6(ip: string): boolean;
|
|
11
13
|
/**
|
|
@@ -13,10 +15,6 @@ export declare function isSafeIpv6(ip: string): boolean;
|
|
|
13
15
|
* Returns false for loopback, private, link-local, or unspecified ranges.
|
|
14
16
|
*/
|
|
15
17
|
export declare function isSafeIp(ip: string): boolean;
|
|
16
|
-
/**
|
|
17
|
-
* Validates a hostname to ensure it is not blocked (e.g. localhost or literal private IP).
|
|
18
|
-
*/
|
|
19
|
-
export declare function validateHostname(hostname: string): void;
|
|
20
18
|
/**
|
|
21
19
|
* Whether the input looks like a URL that simply forgot its `http(s)://` scheme
|
|
22
20
|
* (`example.com`, `docs.nestjs.com/guide`, `192.168.1.1`) rather than unparseable junk.
|
|
@@ -40,6 +38,11 @@ export declare function artifactMatchesUrlFilter(meta: {
|
|
|
40
38
|
source_urls?: string[];
|
|
41
39
|
source_url?: string;
|
|
42
40
|
}, filterUrl: string | undefined): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Reject empty/whitespace `--url` globs on list/prune. Almost always a shell-quoting mistake;
|
|
43
|
+
* matching the empty string silently would look like "no results" instead of bad input.
|
|
44
|
+
*/
|
|
45
|
+
export declare function emptyUrlFilterError(url: string | undefined): string | undefined;
|
|
43
46
|
/**
|
|
44
47
|
* Checks if a filename matches the standard research note naming pattern.
|
|
45
48
|
*/
|
package/dist/lib/research/url.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { isIP } from 'node:net';
|
|
2
2
|
/**
|
|
3
3
|
* Checks if a literal IPv4 address is in a safe range.
|
|
4
|
-
* Rejects loopback, private RFC1918, and link-local
|
|
4
|
+
* Rejects "this network", loopback, private RFC1918, shared address space (CGNAT), and link-local
|
|
5
|
+
* ranges — the non-globally-reachable blocks in the IANA IPv4 special-purpose registry
|
|
6
|
+
* (https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml).
|
|
5
7
|
*/
|
|
6
8
|
export function isSafeIpv4(ip) {
|
|
7
9
|
const parts = ip.split('.').map(Number);
|
|
@@ -10,6 +12,10 @@ export function isSafeIpv4(ip) {
|
|
|
10
12
|
const [a, b, c] = parts;
|
|
11
13
|
if (a === undefined || b === undefined || c === undefined)
|
|
12
14
|
return false;
|
|
15
|
+
// "This network" / unspecified: 0.0.0.0/8 (includes 0.0.0.0 itself, a well-known SSRF-filter
|
|
16
|
+
// bypass — many stacks route it to the loopback interface, same as 127.0.0.1).
|
|
17
|
+
if (a === 0)
|
|
18
|
+
return false;
|
|
13
19
|
// Loopback: 127.0.0.0/8
|
|
14
20
|
if (a === 127)
|
|
15
21
|
return false;
|
|
@@ -20,6 +26,9 @@ export function isSafeIpv4(ip) {
|
|
|
20
26
|
return false;
|
|
21
27
|
if (a === 192 && b === 168)
|
|
22
28
|
return false;
|
|
29
|
+
// Shared Address Space / CGNAT (RFC6598): 100.64.0.0/10
|
|
30
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
31
|
+
return false;
|
|
23
32
|
// Link-local: 169.254.0.0/16
|
|
24
33
|
if (a === 169 && b === 254)
|
|
25
34
|
return false;
|
|
@@ -27,7 +36,7 @@ export function isSafeIpv4(ip) {
|
|
|
27
36
|
}
|
|
28
37
|
/**
|
|
29
38
|
* Checks if a literal IPv6 address is in a safe range.
|
|
30
|
-
* Rejects loopback, unspecified, link-local, and mapped unsafe IPv4 ranges.
|
|
39
|
+
* Rejects loopback, unspecified, link-local, unique-local, and mapped unsafe IPv4 ranges.
|
|
31
40
|
*/
|
|
32
41
|
export function isSafeIpv6(ip) {
|
|
33
42
|
const cleanIp = ip.toLowerCase().trim();
|
|
@@ -44,6 +53,13 @@ export function isSafeIpv6(ip) {
|
|
|
44
53
|
cleanIp.startsWith('feb')) {
|
|
45
54
|
return false;
|
|
46
55
|
}
|
|
56
|
+
// Unique Local Address (RFC4193): fc00::/7 (fc00 to fdff) — IPv6's RFC1918 equivalent, commonly
|
|
57
|
+
// used for internal/container networks. Only a full 4-digit first hextet can reach 0xfc00; a
|
|
58
|
+
// shorter one (leading zeros suppressed) is always < 0x1000 and safely out of range.
|
|
59
|
+
const firstGroup = cleanIp.split(':')[0] ?? '';
|
|
60
|
+
if (firstGroup.length === 4 && (firstGroup.startsWith('fc') || firstGroup.startsWith('fd'))) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
47
63
|
// IPv4-mapped IPv6: ::ffff:127.0.0.1
|
|
48
64
|
if (cleanIp.startsWith('::ffff:')) {
|
|
49
65
|
const ipv4Part = ip.slice(7);
|
|
@@ -65,24 +81,6 @@ export function isSafeIp(ip) {
|
|
|
65
81
|
return isSafeIpv6(ip);
|
|
66
82
|
return false;
|
|
67
83
|
}
|
|
68
|
-
/**
|
|
69
|
-
* Validates a hostname to ensure it is not blocked (e.g. localhost or literal private IP).
|
|
70
|
-
*/
|
|
71
|
-
export function validateHostname(hostname) {
|
|
72
|
-
const lower = hostname.toLowerCase().trim();
|
|
73
|
-
if (lower === 'localhost' || lower.endsWith('.localhost')) {
|
|
74
|
-
throw new Error(`Hostname "${hostname}" is a blocked local target.`);
|
|
75
|
-
}
|
|
76
|
-
let ipToCheck = lower;
|
|
77
|
-
if (ipToCheck.startsWith('[') && ipToCheck.endsWith(']')) {
|
|
78
|
-
ipToCheck = ipToCheck.slice(1, -1);
|
|
79
|
-
}
|
|
80
|
-
if (isIP(ipToCheck) !== 0) {
|
|
81
|
-
if (!isSafeIp(ipToCheck)) {
|
|
82
|
-
throw new Error(`IP address "${ipToCheck}" is a blocked local or private target.`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
84
|
// Dot-separated, non-empty labels: a real domain (`docs.nestjs.com`) or IPv4 (`192.168.1.1`). The
|
|
87
85
|
// URL parser also accepts a leading-dot filename (`.env`) or relative path (`./x`) as a "host", whose
|
|
88
86
|
// suggestion would be a nonsense `https://.env`; requiring well-formed labels rejects those. Anchored
|
|
@@ -133,7 +131,9 @@ export function normalizeUrl(input) {
|
|
|
133
131
|
if (url.username || url.password) {
|
|
134
132
|
throw new Error('URLs containing usernames or passwords are rejected.');
|
|
135
133
|
}
|
|
136
|
-
|
|
134
|
+
// Localhost / private hosts are valid cache keys (import + cache-hit lookup). SSRF protection
|
|
135
|
+
// lives in the network layer (fetcher/browser DNS checks), so a later `bonsai http://localhost/…`
|
|
136
|
+
// can serve an imported note without ever opening a socket — while a cache miss still fails safely.
|
|
137
137
|
// Clear default ports
|
|
138
138
|
if (url.protocol === 'https:' && url.port === '443') {
|
|
139
139
|
url.port = '';
|
|
@@ -175,6 +175,18 @@ export function artifactMatchesUrlFilter(meta, filterUrl) {
|
|
|
175
175
|
const urls = meta.source_urls || (meta.source_url ? [meta.source_url] : []);
|
|
176
176
|
return urls.some((u) => matchesUrlGlob(u, filterUrl));
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Reject empty/whitespace `--url` globs on list/prune. Almost always a shell-quoting mistake;
|
|
180
|
+
* matching the empty string silently would look like "no results" instead of bad input.
|
|
181
|
+
*/
|
|
182
|
+
export function emptyUrlFilterError(url) {
|
|
183
|
+
if (url === undefined)
|
|
184
|
+
return undefined;
|
|
185
|
+
if (url.trim() === '') {
|
|
186
|
+
return '--url must be a non-empty glob pattern (e.g. "https://react.dev/*").';
|
|
187
|
+
}
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
178
190
|
/**
|
|
179
191
|
* Checks if a filename matches the standard research note naming pattern.
|
|
180
192
|
*/
|