@taurgis/bonsai 3.2.0 → 3.2.2
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 +7 -4
- package/agents/agents/salesforce-docs-researcher.agent.md +14 -1
- package/agents/instructions/salesforce-research.instructions.md +2 -1
- package/bin/cli.mjs +9 -17
- package/dist/base-command.d.ts +22 -12
- package/dist/base-command.js +86 -49
- package/dist/commands/config/base.js +11 -2
- package/dist/commands/config/get.js +8 -12
- package/dist/commands/config/index.js +2 -2
- package/dist/commands/config/list.js +10 -16
- package/dist/commands/config/set.d.ts +2 -1
- package/dist/commands/config/set.js +34 -27
- package/dist/commands/config/unset.d.ts +2 -1
- package/dist/commands/config/unset.js +25 -24
- package/dist/commands/fetch.d.ts +7 -15
- package/dist/commands/fetch.js +57 -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 +103 -52
- package/dist/commands/list.d.ts +13 -1
- package/dist/commands/list.js +79 -32
- package/dist/commands/prune.d.ts +4 -1
- package/dist/commands/prune.js +55 -75
- package/dist/commands/status.d.ts +3 -2
- package/dist/commands/status.js +52 -79
- package/dist/hooks/command-not-found/suggest.d.ts +1 -0
- package/dist/hooks/command-not-found/suggest.js +66 -39
- package/dist/lib/argv.d.ts +24 -3
- package/dist/lib/argv.js +124 -53
- 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 +5 -0
- package/dist/lib/cli-flag-manifest.js +8 -0
- package/dist/lib/cli-presentation.d.ts +39 -0
- package/dist/lib/cli-presentation.js +50 -0
- package/dist/lib/cli-result-types.d.ts +88 -0
- package/dist/lib/cli-result-types.js +2 -0
- package/dist/lib/config/index.d.ts +21 -3
- package/dist/lib/config/index.js +58 -3
- 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 +147 -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 +28 -0
- package/dist/lib/prune-flags.js +74 -0
- package/dist/lib/research/artifact-index.d.ts +11 -2
- package/dist/lib/research/artifact-index.js +7 -4
- package/dist/lib/research/browser.d.ts +41 -2
- package/dist/lib/research/browser.js +139 -50
- 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 +33 -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 +310 -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 +58 -30
- 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 +381 -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/persist-artifact.d.ts +23 -0
- package/dist/lib/research/persist-artifact.js +27 -0
- package/dist/lib/research/prompt-injection.d.ts +8 -0
- package/dist/lib/research/prompt-injection.js +42 -13
- package/dist/lib/research/proxy.d.ts +14 -8
- package/dist/lib/research/proxy.js +14 -10
- 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 +5 -1
- package/dist/lib/research/storage.js +8 -3
- 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 +7 -2
- package/dist/lib/text.js +13 -2
- 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 +104 -98
- package/package.json +19 -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
|
@@ -47,15 +47,23 @@ function addGfmTableRule(service) {
|
|
|
47
47
|
});
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* Drops empty anchor links (e.g. `[](url)`) from Markdown. These arise from icon-only heading
|
|
51
|
+
* permalinks and ReadMe query-param chips that carry no text content, wasting tokens.
|
|
52
|
+
* Images (``) are intentionally not matched.
|
|
53
|
+
*
|
|
54
|
+
* @param markdown - Markdown text to clean.
|
|
55
|
+
* @returns Markdown with text-less links removed.
|
|
52
56
|
*/
|
|
53
|
-
// Icon-only/empty anchors (e.g. a heading's "#" permalink, a ReadMe query-param chip) convert to a
|
|
54
|
-
// text-less link `[](url)` that carries no information and just wastes tokens. Drop them, but never
|
|
55
|
-
// match images `` (empty alt is still a real image).
|
|
56
57
|
export function dropEmptyLinks(markdown) {
|
|
57
58
|
return markdown.replace(/(?<!!)\[\s*\]\([^)]*\)/g, '');
|
|
58
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Converts HTML content to clean, deterministic Markdown.
|
|
62
|
+
* Configured to use ATX-style headings (#) and fenced code blocks (```), with GFM tables.
|
|
63
|
+
*
|
|
64
|
+
* @param html - HTML string to convert.
|
|
65
|
+
* @returns Clean Markdown with empty links stripped.
|
|
66
|
+
*/
|
|
59
67
|
export function htmlToMarkdown(html) {
|
|
60
68
|
const turndownService = new TurndownService({
|
|
61
69
|
headingStyle: 'atx',
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type SecureWriteResult } from './secure-write.js';
|
|
2
|
+
import type { StoreRoots } from './store-roots.js';
|
|
3
|
+
import type { ResearchArtifact } from './schema.js';
|
|
4
|
+
export type ArtifactWriteKind = 'import' | 'fetch';
|
|
5
|
+
export interface PersistArtifactResult extends SecureWriteResult {
|
|
6
|
+
dryRun: boolean;
|
|
7
|
+
/** Secret-redirect warning for stderr, or null when no redirect. */
|
|
8
|
+
redirectWarning: string | null;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Persist (or dry-run preview) an artifact with secret-safe routing. Owns redirect warning copy
|
|
12
|
+
* so fetch/import cannot drift. When `scratchDir` is set (fetch dry-run), also writes a throwaway
|
|
13
|
+
* copy there for downstream extraction while reporting the real would-be `dataDir`.
|
|
14
|
+
*/
|
|
15
|
+
export declare function persistArtifact(opts: {
|
|
16
|
+
roots: StoreRoots;
|
|
17
|
+
cacheKey: string;
|
|
18
|
+
artifact: ResearchArtifact;
|
|
19
|
+
dryRun: boolean;
|
|
20
|
+
kind: ArtifactWriteKind;
|
|
21
|
+
/** Fetch dry-run throwaway directory; real destination still comes from the secure write. */
|
|
22
|
+
scratchDir?: string | null;
|
|
23
|
+
}): PersistArtifactResult;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { writeArtifact } from './storage.js';
|
|
2
|
+
import { writeArtifactSecurely } from './secure-write.js';
|
|
3
|
+
function redirectWarningFor(kind, dryRun, secretLabel) {
|
|
4
|
+
const content = kind === 'import' ? 'imported content' : 'page content';
|
|
5
|
+
const verb = dryRun ? 'would store' : 'stored';
|
|
6
|
+
return `Detected ${secretLabel} in the ${content}; ${verb} in the global cache instead of the project to avoid committing secrets.`;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Persist (or dry-run preview) an artifact with secret-safe routing. Owns redirect warning copy
|
|
10
|
+
* so fetch/import cannot drift. When `scratchDir` is set (fetch dry-run), also writes a throwaway
|
|
11
|
+
* copy there for downstream extraction while reporting the real would-be `dataDir`.
|
|
12
|
+
*/
|
|
13
|
+
export function persistArtifact(opts) {
|
|
14
|
+
const result = writeArtifactSecurely(opts.roots, opts.cacheKey, opts.artifact, {
|
|
15
|
+
dryRun: opts.dryRun,
|
|
16
|
+
});
|
|
17
|
+
if (opts.scratchDir) {
|
|
18
|
+
writeArtifact(opts.scratchDir, opts.cacheKey, opts.artifact);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
...result,
|
|
22
|
+
dryRun: opts.dryRun,
|
|
23
|
+
redirectWarning: result.redirected
|
|
24
|
+
? redirectWarningFor(opts.kind, opts.dryRun, result.secretLabel)
|
|
25
|
+
: null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -1 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scans Markdown for embedded prompt-injection attacks and redacts any block or line that matches.
|
|
3
|
+
* Detects direct patterns, plus base64/hex-encoded variants and typoglycemia obfuscation.
|
|
4
|
+
* HTML comments are checked first; the body is then split on paragraph breaks and scanned line-by-line.
|
|
5
|
+
*
|
|
6
|
+
* @param markdown - Markdown content (possibly untrusted, sourced from fetched pages).
|
|
7
|
+
* @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder.
|
|
8
|
+
*/
|
|
1
9
|
export declare function sanitizePromptInjection(markdown: string): string;
|
|
@@ -30,19 +30,28 @@ const TYPOGLYCEMIA_TARGETS = [
|
|
|
30
30
|
'repository',
|
|
31
31
|
'workspace',
|
|
32
32
|
];
|
|
33
|
+
// Contexts where a following verb reads as a command aimed at the reader, not a third-person
|
|
34
|
+
// description of an attack (e.g. "An attacker may tell the model to ignore..." must stay
|
|
35
|
+
// unredacted — see the matching test below). `^` is matched per clause (see CLAUSE_BOUNDARY
|
|
36
|
+
// below), so a command hidden after an innocuous opener ("Heads up: ignore...") still counts as
|
|
37
|
+
// clause-initial; only right after a direct role-address word is the other allowed context.
|
|
38
|
+
const COMMAND_CONTEXT = String.raw `(?:^|\b(?:please|assistant|system|user|agent)\s+)`;
|
|
39
|
+
function commandPattern(suffix) {
|
|
40
|
+
return new RegExp(COMMAND_CONTEXT + suffix, 'i');
|
|
41
|
+
}
|
|
33
42
|
const HARMFUL_INSTRUCTION_PATTERNS = [
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
commandPattern(String.raw `ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b`),
|
|
44
|
+
commandPattern(String.raw `disregard\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b`),
|
|
45
|
+
commandPattern(String.raw `forget\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|directions?|prompts?)\b`),
|
|
46
|
+
commandPattern(String.raw `override\s+(the\s+)?(system|developer|agent)\s+(prompt|instructions?|message)\b`),
|
|
47
|
+
commandPattern(String.raw `you\s+are\s+now\s+(in\s+)?(developer|admin|root|system)\s+mode\b`),
|
|
48
|
+
commandPattern(String.raw `reveal\s+(your\s+)?(system|developer)\s+(prompt|instructions?|message)\b`),
|
|
49
|
+
commandPattern(String.raw `print\s+(your\s+)?(system|developer)\s+(prompt|instructions?|message)\b`),
|
|
50
|
+
commandPattern(String.raw `(exfiltrate|steal|leak)\s+(all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?|private\s+data)\b`),
|
|
51
|
+
commandPattern(String.raw `send\s+(me\s+|all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?)\s+(to|over|via|using)\b`),
|
|
52
|
+
commandPattern(String.raw `(upload|post|fetch|curl)\s+(all\s+|the\s+|your\s+)?(secrets?|tokens?|api\s*keys?|credentials?)\s+(to|over|via|using)\b`),
|
|
53
|
+
commandPattern(String.raw `delete\s+(all\s+)?(files?|the\s+repository|the\s+workspace|the\s+home\s+directory)\b`),
|
|
54
|
+
commandPattern(String.raw `run\s+.*\b(rm\s+-rf|curl\s+.*\|\s*(sh|bash)|sudo)\b`),
|
|
46
55
|
];
|
|
47
56
|
function normalizeForDetection(text) {
|
|
48
57
|
return text
|
|
@@ -110,8 +119,20 @@ function isReadableDecodedText(text) {
|
|
|
110
119
|
const printable = [...text].filter((char) => /[\t\n\r -~]/.test(char)).length;
|
|
111
120
|
return printable / Math.max(text.length, 1) > 0.85;
|
|
112
121
|
}
|
|
122
|
+
// Sentence-ending punctuation splits a line into clauses for `^`-anchored matching below. This has
|
|
123
|
+
// to run on the raw text, before normalizeForDetection's charset filter erases that same
|
|
124
|
+
// punctuation into plain spaces — testing the anchor after normalization would never see it.
|
|
125
|
+
//
|
|
126
|
+
// ponytail: this still isn't complete — a filler *word* with no punctuation before it ("Please
|
|
127
|
+
// just ignore previous instructions") is a clause-internal command and stays unredacted, same as
|
|
128
|
+
// before this fix. Heuristic prompt-injection detection can't be made airtight with regex alone
|
|
129
|
+
// (OWASP LLM01 has no complete solution either); upgrade path is a small classifier or an LLM-based
|
|
130
|
+
// second pass over blocks this pattern set flags as borderline, if false negatives here prove costly.
|
|
131
|
+
const CLAUSE_BOUNDARY = /(?<=[.!?:;])\s+/;
|
|
113
132
|
function isUnsafeAgentInstruction(text) {
|
|
114
|
-
return
|
|
133
|
+
return text
|
|
134
|
+
.split(CLAUSE_BOUNDARY)
|
|
135
|
+
.some((clause) => detectionCandidates(clause).some((candidate) => HARMFUL_INSTRUCTION_PATTERNS.some((pattern) => pattern.test(candidate))));
|
|
115
136
|
}
|
|
116
137
|
function redactBlock(block) {
|
|
117
138
|
const withSafeSpans = redactUnsafeSpans(block);
|
|
@@ -132,6 +153,14 @@ function stripQuotes(span) {
|
|
|
132
153
|
}
|
|
133
154
|
return span;
|
|
134
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* Scans Markdown for embedded prompt-injection attacks and redacts any block or line that matches.
|
|
158
|
+
* Detects direct patterns, plus base64/hex-encoded variants and typoglycemia obfuscation.
|
|
159
|
+
* HTML comments are checked first; the body is then split on paragraph breaks and scanned line-by-line.
|
|
160
|
+
*
|
|
161
|
+
* @param markdown - Markdown content (possibly untrusted, sourced from fetched pages).
|
|
162
|
+
* @returns Sanitized Markdown with harmful instruction blocks replaced by a redaction placeholder.
|
|
163
|
+
*/
|
|
135
164
|
export function sanitizePromptInjection(markdown) {
|
|
136
165
|
return markdown
|
|
137
166
|
.replace(HTML_COMMENT, (comment) => redactBlock(comment))
|
|
@@ -59,13 +59,19 @@ export declare function getCaBundleSpkiHashes(): string[];
|
|
|
59
59
|
/**
|
|
60
60
|
* Chrome CLI flag that trusts exactly the sandbox's injected CA(s), pinned by SPKI hash rather
|
|
61
61
|
* than by disabling verification. Chromium's TLS stack (the Chrome Root Store) never reads
|
|
62
|
-
* NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/CURL_CA_BUNDLE the way Node/curl/Python do, so
|
|
63
|
-
* MITMs HTTPS
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
62
|
+
* NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/CURL_CA_BUNDLE the way Node/curl/Python do, so an environment
|
|
63
|
+
* that MITMs HTTPS makes every other tool trust that CA while Chrome's handshake with it fails
|
|
64
|
+
* with ERR_CERT_AUTHORITY_INVALID. This is scoped narrowly to the specific CA(s) discovered via
|
|
65
|
+
* the CA bundle env vars — never --ignore-certificate-errors, which would disable verification
|
|
66
|
+
* for every host, including ones that have nothing to do with the sandbox.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately independent of isProxyConfigured(): some sandboxes intercept HTTPS transparently
|
|
69
|
+
* (or via a proxy Chrome reaches without an explicit --proxy-server flag) while still exporting a
|
|
70
|
+
* CA bundle env var for CLI tools to trust — HTTPS_PROXY/HTTP_PROXY and the CA bundle vars are
|
|
71
|
+
* two separate signals that usually co-occur but aren't guaranteed to. Gating on proxy detection
|
|
72
|
+
* left Chrome untrusting a CA bundle that was in fact discoverable, so it rejected every
|
|
73
|
+
* browser-rendered fetch with ERR_CERT_AUTHORITY_INVALID in that shape of environment. A no-op
|
|
74
|
+
* (empty array) when no CA bundle is discoverable, so default Chrome trust is untouched on
|
|
75
|
+
* ordinary developer machines.
|
|
70
76
|
*/
|
|
71
77
|
export declare function getChromeSpkiArgs(): string[];
|
|
@@ -169,18 +169,22 @@ export function getCaBundleSpkiHashes() {
|
|
|
169
169
|
/**
|
|
170
170
|
* Chrome CLI flag that trusts exactly the sandbox's injected CA(s), pinned by SPKI hash rather
|
|
171
171
|
* than by disabling verification. Chromium's TLS stack (the Chrome Root Store) never reads
|
|
172
|
-
* NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/CURL_CA_BUNDLE the way Node/curl/Python do, so
|
|
173
|
-
* MITMs HTTPS
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
172
|
+
* NODE_EXTRA_CA_CERTS/SSL_CERT_FILE/CURL_CA_BUNDLE the way Node/curl/Python do, so an environment
|
|
173
|
+
* that MITMs HTTPS makes every other tool trust that CA while Chrome's handshake with it fails
|
|
174
|
+
* with ERR_CERT_AUTHORITY_INVALID. This is scoped narrowly to the specific CA(s) discovered via
|
|
175
|
+
* the CA bundle env vars — never --ignore-certificate-errors, which would disable verification
|
|
176
|
+
* for every host, including ones that have nothing to do with the sandbox.
|
|
177
|
+
*
|
|
178
|
+
* Deliberately independent of isProxyConfigured(): some sandboxes intercept HTTPS transparently
|
|
179
|
+
* (or via a proxy Chrome reaches without an explicit --proxy-server flag) while still exporting a
|
|
180
|
+
* CA bundle env var for CLI tools to trust — HTTPS_PROXY/HTTP_PROXY and the CA bundle vars are
|
|
181
|
+
* two separate signals that usually co-occur but aren't guaranteed to. Gating on proxy detection
|
|
182
|
+
* left Chrome untrusting a CA bundle that was in fact discoverable, so it rejected every
|
|
183
|
+
* browser-rendered fetch with ERR_CERT_AUTHORITY_INVALID in that shape of environment. A no-op
|
|
184
|
+
* (empty array) when no CA bundle is discoverable, so default Chrome trust is untouched on
|
|
185
|
+
* ordinary developer machines.
|
|
180
186
|
*/
|
|
181
187
|
export function getChromeSpkiArgs() {
|
|
182
|
-
if (!isProxyConfigured())
|
|
183
|
-
return [];
|
|
184
188
|
const hashes = getCaBundleSpkiHashes();
|
|
185
189
|
if (!hashes.length)
|
|
186
190
|
return [];
|
|
@@ -1,34 +1,63 @@
|
|
|
1
1
|
import type { ResearchArtifact } from './schema.js';
|
|
2
|
+
import { type FetchedContent } from './fetcher.js';
|
|
3
|
+
import { type ExtractionResult } from './extract.js';
|
|
4
|
+
import type { StoreRoots } from './store-roots.js';
|
|
2
5
|
import type { SummaryLevel } from '../config/schema.js';
|
|
6
|
+
/**
|
|
7
|
+
* When set, project-located revalidation writes reuse {@link persistArtifact} (secret-scan +
|
|
8
|
+
* redirect) so a refresh cannot leave credentials in a committable project cache (#90).
|
|
9
|
+
*/
|
|
10
|
+
export interface RevalidateSecureWrite {
|
|
11
|
+
roots: StoreRoots;
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
scratchDir?: string | null;
|
|
14
|
+
}
|
|
15
|
+
/** Result of conditional revalidation or a full refresh when validators miss. */
|
|
3
16
|
export interface RevalidationResult {
|
|
17
|
+
/** Outcome of the revalidation attempt. */
|
|
4
18
|
status: 'revalidated' | 'refreshed' | 'stale';
|
|
19
|
+
/** Artifact to serve (updated, refreshed, or the prior stale entry). */
|
|
5
20
|
artifact: ResearchArtifact;
|
|
21
|
+
/** When status is `stale`, whether serving stale content is allowed. */
|
|
6
22
|
allowed?: boolean;
|
|
23
|
+
/** Revalidation failure message when status is `stale`. */
|
|
7
24
|
error?: string;
|
|
25
|
+
/** Data dir the write landed in (or would land in under dry-run), when a write ran. */
|
|
26
|
+
storageDir?: string;
|
|
27
|
+
/** True when a project refresh was redirected to global due to a detected secret. */
|
|
28
|
+
redirectedToGlobal?: boolean;
|
|
29
|
+
/** Secret-redirect warning for stderr, or null/undefined when none. */
|
|
30
|
+
redirectWarning?: string | null;
|
|
31
|
+
}
|
|
32
|
+
type RevalidateOptions = {
|
|
33
|
+
allowStale: boolean;
|
|
34
|
+
ttlOverride?: string | null;
|
|
35
|
+
rendered?: boolean;
|
|
36
|
+
summaryLevel: SummaryLevel;
|
|
37
|
+
/** Present only when the live entry is in the project cache. */
|
|
38
|
+
secure?: RevalidateSecureWrite;
|
|
39
|
+
};
|
|
40
|
+
interface CreateArtifactFromFetchInput {
|
|
41
|
+
url: string;
|
|
42
|
+
normalizedUrl: string;
|
|
43
|
+
cacheKey: string;
|
|
44
|
+
fetchResult: FetchedContent;
|
|
45
|
+
extraction: ExtractionResult;
|
|
46
|
+
tier: 'stable' | 'standard' | 'volatile';
|
|
47
|
+
ttl: string | null;
|
|
48
|
+
currentTime: Date;
|
|
49
|
+
summaryLevel: SummaryLevel;
|
|
8
50
|
}
|
|
9
51
|
/**
|
|
10
|
-
*
|
|
52
|
+
* Construct a ResearchArtifact from a fresh HTML fetch (or error-page marker).
|
|
53
|
+
*
|
|
54
|
+
* @param input - URL, fetch result, extraction, tier/TTL, clock, and summary level.
|
|
55
|
+
* @returns A ready-to-persist research artifact.
|
|
11
56
|
*/
|
|
12
|
-
export declare function createArtifactFromFetch(
|
|
13
|
-
contentType: string | null;
|
|
14
|
-
etag: string | null;
|
|
15
|
-
lastModified: string | null;
|
|
16
|
-
finalUrl: string;
|
|
17
|
-
responseSize: number;
|
|
18
|
-
content: string;
|
|
19
|
-
}, extraction: {
|
|
20
|
-
title: string;
|
|
21
|
-
detailedMarkdown: string;
|
|
22
|
-
confidence: 'high' | 'medium' | 'low';
|
|
23
|
-
qualityNotes: string[];
|
|
24
|
-
}, tier: 'stable' | 'standard' | 'volatile', ttl: string | null, currentTime: Date, summaryLevel: SummaryLevel): ResearchArtifact;
|
|
57
|
+
export declare function createArtifactFromFetch(input: CreateArtifactFromFetchInput): ResearchArtifact;
|
|
25
58
|
/**
|
|
26
59
|
* Revalidates a stale cache artifact using conditional request headers,
|
|
27
60
|
* falling back to stale serving within the grace period if the remote host is offline.
|
|
28
61
|
*/
|
|
29
|
-
export declare function revalidateCache(dataDir: string, existing: ResearchArtifact, currentTime: Date, options:
|
|
30
|
-
|
|
31
|
-
ttlOverride?: string | null;
|
|
32
|
-
rendered?: boolean;
|
|
33
|
-
summaryLevel: SummaryLevel;
|
|
34
|
-
}): Promise<RevalidationResult>;
|
|
62
|
+
export declare function revalidateCache(dataDir: string, existing: ResearchArtifact, currentTime: Date, options: RevalidateOptions): Promise<RevalidationResult>;
|
|
63
|
+
export {};
|
|
@@ -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
|
}
|
|
@@ -48,5 +48,9 @@ export declare function locateArtifact(dataDirs: string[], key: string): Located
|
|
|
48
48
|
* order and deduplicates by cache key so an entry present in an earlier root (project) shadows
|
|
49
49
|
* the same key in a later root (global) — even when the callback filters it out. Composes
|
|
50
50
|
* scanCacheDir so the file-format and read/parse rules stay defined in exactly one place.
|
|
51
|
+
*
|
|
52
|
+
* Pass `persistIndex: false` under read-only/plan mode so listing never creates `.search-index.json`.
|
|
51
53
|
*/
|
|
52
|
-
export declare function scanCacheDirs<T>(dataDirs: string[], fn: (artifact: ResearchArtifact, filePath: string) => T | null
|
|
54
|
+
export declare function scanCacheDirs<T>(dataDirs: string[], fn: (artifact: ResearchArtifact, filePath: string) => T | null, options?: {
|
|
55
|
+
persistIndex?: boolean;
|
|
56
|
+
}): T[];
|