@deepwatch/dsh-contracts 0.1.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/LICENSE +21 -0
- package/README.md +77 -0
- package/lib/approval.d.ts +114 -0
- package/lib/approval.js +108 -0
- package/lib/bindings.d.ts +248 -0
- package/lib/bindings.js +333 -0
- package/lib/digests.d.ts +77 -0
- package/lib/digests.js +103 -0
- package/lib/execution.d.ts +259 -0
- package/lib/execution.js +213 -0
- package/lib/failures.d.ts +145 -0
- package/lib/failures.js +204 -0
- package/lib/identity.d.ts +54 -0
- package/lib/identity.js +74 -0
- package/lib/index.d.ts +382 -0
- package/lib/index.js +79 -0
- package/lib/language.d.ts +158 -0
- package/lib/language.js +202 -0
- package/lib/paths.d.ts +194 -0
- package/lib/paths.js +286 -0
- package/lib/presentation.d.ts +90 -0
- package/lib/presentation.js +158 -0
- package/lib/query/validate.d.ts +74 -0
- package/lib/query/validate.js +243 -0
- package/lib/query/wire.d.ts +409 -0
- package/lib/query/wire.js +37 -0
- package/lib/query.d.ts +362 -0
- package/lib/query.js +431 -0
- package/lib/readiness.d.ts +198 -0
- package/lib/readiness.js +209 -0
- package/lib/workspace.d.ts +141 -0
- package/lib/workspace.js +158 -0
- package/package.json +60 -0
package/lib/language.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language-aware evidence.
|
|
3
|
+
*
|
|
4
|
+
* The correction this module encodes (spec §37): Watch is not an English
|
|
5
|
+
* product with translations bolted on, and it is not an Arabic one either. It
|
|
6
|
+
* is language-independent architecture with a *measured* support matrix — and
|
|
7
|
+
* the two claims are very different. "Handles Unicode" is architecture.
|
|
8
|
+
* "Reads Thai subtitles well" is a measurement, and it belongs to a
|
|
9
|
+
* qualification result rather than to a type.
|
|
10
|
+
*
|
|
11
|
+
* Five things are kept separate that products routinely conflate:
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* UI locale what the interface is in
|
|
15
|
+
* source language(s) what the material is in
|
|
16
|
+
* script(s) what alphabet it is written in
|
|
17
|
+
* response language what the agent answers in
|
|
18
|
+
* translation target an optional derived view
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Changing the interface language must not translate evidence. A source can
|
|
22
|
+
* contain several languages in one frame. And a translation is *derived data
|
|
23
|
+
* with its own provenance* — the original text stays the evidence, because a
|
|
24
|
+
* citation that resolved to a translation would be citing something no one
|
|
25
|
+
* ever said.
|
|
26
|
+
*
|
|
27
|
+
* @module @deepwatch/dsh-contracts/language
|
|
28
|
+
*/
|
|
29
|
+
/** Scripts that are written right to left. */
|
|
30
|
+
const RTL_SCRIPTS = new Set(['Arabic', 'Hebrew']);
|
|
31
|
+
/**
|
|
32
|
+
* Detect the scripts present in a string.
|
|
33
|
+
*
|
|
34
|
+
* Range-based rather than library-based on purpose: this runs on the browser
|
|
35
|
+
* side of an evidence panel, and pulling a full ICU table into a bundle to
|
|
36
|
+
* answer "is there Arabic in here" is not a trade worth making. It is
|
|
37
|
+
* deliberately coarse — it reports which scripts appear, which is what
|
|
38
|
+
* direction and routing need, not a language identification.
|
|
39
|
+
*/
|
|
40
|
+
export function detectScripts(text) {
|
|
41
|
+
const found = new Set();
|
|
42
|
+
for (const character of text) {
|
|
43
|
+
const code = character.codePointAt(0);
|
|
44
|
+
if (code === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
// Skip anything that carries no script identity: digits, punctuation,
|
|
47
|
+
// whitespace. Counting them would make every string "Latin".
|
|
48
|
+
if (code < 0x0041)
|
|
49
|
+
continue;
|
|
50
|
+
if (code <= 0x024f)
|
|
51
|
+
found.add('Latin');
|
|
52
|
+
else if (code >= 0x0370 && code <= 0x03ff)
|
|
53
|
+
found.add('Greek');
|
|
54
|
+
else if (code >= 0x0400 && code <= 0x04ff)
|
|
55
|
+
found.add('Cyrillic');
|
|
56
|
+
else if (code >= 0x0590 && code <= 0x05ff)
|
|
57
|
+
found.add('Hebrew');
|
|
58
|
+
else if (code >= 0x0600 && code <= 0x06ff)
|
|
59
|
+
found.add('Arabic');
|
|
60
|
+
else if (code >= 0x0750 && code <= 0x077f)
|
|
61
|
+
found.add('Arabic');
|
|
62
|
+
else if (code >= 0x0900 && code <= 0x097f)
|
|
63
|
+
found.add('Devanagari');
|
|
64
|
+
else if (code >= 0x0e00 && code <= 0x0e7f)
|
|
65
|
+
found.add('Thai');
|
|
66
|
+
else if (code >= 0x0e80 && code <= 0x0eff)
|
|
67
|
+
found.add('Lao');
|
|
68
|
+
else if (code >= 0x0f00 && code <= 0x0fff)
|
|
69
|
+
found.add('Tibetan');
|
|
70
|
+
else if (code >= 0x1000 && code <= 0x109f)
|
|
71
|
+
found.add('Myanmar');
|
|
72
|
+
else if (code >= 0x1780 && code <= 0x17ff)
|
|
73
|
+
found.add('Khmer');
|
|
74
|
+
else if (code >= 0x3040 && code <= 0x309f)
|
|
75
|
+
found.add('Hiragana');
|
|
76
|
+
else if (code >= 0x30a0 && code <= 0x30ff)
|
|
77
|
+
found.add('Katakana');
|
|
78
|
+
else if (code >= 0x4e00 && code <= 0x9fff)
|
|
79
|
+
found.add('Han');
|
|
80
|
+
else if (code >= 0xac00 && code <= 0xd7af)
|
|
81
|
+
found.add('Hangul');
|
|
82
|
+
else if (code >= 0xfb50 && code <= 0xfdff)
|
|
83
|
+
found.add('Arabic');
|
|
84
|
+
else if (code >= 0xfe70 && code <= 0xfeff)
|
|
85
|
+
found.add('Arabic');
|
|
86
|
+
}
|
|
87
|
+
return found.size === 0 ? ['Unknown'] : [...found].sort();
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Which way a string reads.
|
|
91
|
+
*
|
|
92
|
+
* `mixed` is a real answer, not a failure to decide. A layout that renders
|
|
93
|
+
* mixed content as though it were uniformly one direction produces text that
|
|
94
|
+
* is technically present and unreadable.
|
|
95
|
+
*/
|
|
96
|
+
export function detectDirection(text) {
|
|
97
|
+
const scripts = detectScripts(text);
|
|
98
|
+
if (scripts.length === 1 && scripts[0] === 'Unknown')
|
|
99
|
+
return 'unknown';
|
|
100
|
+
const hasRtl = scripts.some(script => RTL_SCRIPTS.has(script));
|
|
101
|
+
const hasLtr = scripts.some(script => script !== 'Unknown' && !RTL_SCRIPTS.has(script));
|
|
102
|
+
if (hasRtl && hasLtr)
|
|
103
|
+
return 'mixed';
|
|
104
|
+
if (hasRtl)
|
|
105
|
+
return 'rtl';
|
|
106
|
+
return 'ltr';
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Normalize text for retrieval.
|
|
110
|
+
*
|
|
111
|
+
* NFKC plus case folding plus combining-mark removal: enough that a query
|
|
112
|
+
* typed without diacritics finds text written with them, and that half-width
|
|
113
|
+
* and full-width forms match. Deliberately not stemming or transliteration —
|
|
114
|
+
* those change what a word means, and an index that matched across them would
|
|
115
|
+
* return hits a person cannot see the reason for.
|
|
116
|
+
*/
|
|
117
|
+
export function normalizeForRetrieval(text) {
|
|
118
|
+
return text
|
|
119
|
+
// NFKD, not NFKC. Compatibility *decomposition* is what separates a
|
|
120
|
+
// precomposed É into E plus a combining acute so the next step can remove
|
|
121
|
+
// it; NFKC would compose it back into one codepoint that matches no
|
|
122
|
+
// combining-mark pattern, and the diacritic would survive the fold.
|
|
123
|
+
.normalize('NFKD')
|
|
124
|
+
.toLowerCase()
|
|
125
|
+
// Strip combining marks. This folds Arabic and Hebrew vowel points and
|
|
126
|
+
// Latin accents alike, which is what makes a query match text that was
|
|
127
|
+
// written more carefully than it was typed.
|
|
128
|
+
.replace(/\p{M}+/gu, '')
|
|
129
|
+
// Recompose. NFKD also splits Hangul syllables into jamo, and leaving them
|
|
130
|
+
// apart would mean composed and decomposed Korean no longer match each
|
|
131
|
+
// other — the exact failure this function exists to prevent.
|
|
132
|
+
.normalize('NFC')
|
|
133
|
+
.replace(/\s+/gu, ' ')
|
|
134
|
+
.trim();
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Build the language-aware text for an observed span.
|
|
138
|
+
*
|
|
139
|
+
* The normalized form is computed here rather than accepted from a caller, so
|
|
140
|
+
* every index in the product folds text the same way. Two normalizers that
|
|
141
|
+
* disagree produce a search that finds a result in one surface and not in
|
|
142
|
+
* another, for reasons nobody can see.
|
|
143
|
+
*/
|
|
144
|
+
export function describeText(originalText, producer, producerVersion, options = {}) {
|
|
145
|
+
return {
|
|
146
|
+
originalText,
|
|
147
|
+
normalizedText: normalizeForRetrieval(originalText),
|
|
148
|
+
languageTags: options.languageTags ?? [],
|
|
149
|
+
scripts: detectScripts(originalText),
|
|
150
|
+
direction: detectDirection(originalText),
|
|
151
|
+
languageConfidence: options.languageConfidence ?? null,
|
|
152
|
+
producer,
|
|
153
|
+
producerVersion,
|
|
154
|
+
translations: [],
|
|
155
|
+
qualityWarnings: options.qualityWarnings ?? [],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Attach a translation without disturbing the original.
|
|
160
|
+
*
|
|
161
|
+
* Returns a new value. The original text is unchanged and stays first, because
|
|
162
|
+
* the moment a translation could overwrite it, a citation would resolve to
|
|
163
|
+
* something nobody said.
|
|
164
|
+
*/
|
|
165
|
+
export function withTranslation(text, translation) {
|
|
166
|
+
return { ...text, translations: [...text.translations, translation] };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* What a surface should display, given a reader's preference.
|
|
170
|
+
*
|
|
171
|
+
* Returns the original unless a translation for the requested language exists,
|
|
172
|
+
* and always says which it returned. A caller that renders this without
|
|
173
|
+
* showing `isOriginal` is presenting derived text as observed text.
|
|
174
|
+
*/
|
|
175
|
+
export function displayText(text, preferredLanguage) {
|
|
176
|
+
if (preferredLanguage === null) {
|
|
177
|
+
return { text: text.originalText, isOriginal: true, direction: text.direction };
|
|
178
|
+
}
|
|
179
|
+
const translation = text.translations.find(entry => entry.targetLanguage === preferredLanguage);
|
|
180
|
+
if (translation === undefined) {
|
|
181
|
+
return { text: text.originalText, isOriginal: true, direction: text.direction };
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
text: translation.text,
|
|
185
|
+
isOriginal: false,
|
|
186
|
+
// The translation's own direction, not the original's: rendering Arabic
|
|
187
|
+
// output with the source's ltr direction produces unreadable text.
|
|
188
|
+
direction: detectDirection(translation.text),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Whether a span must be isolated from the surrounding text direction.
|
|
193
|
+
*
|
|
194
|
+
* Code, URLs, identifiers and timestamps read left to right inside an
|
|
195
|
+
* otherwise right-to-left paragraph, and without isolation the bidi algorithm
|
|
196
|
+
* reorders them into something that looks plausible and is wrong — a path with
|
|
197
|
+
* its segments reversed, a timestamp reading 30:2.
|
|
198
|
+
*/
|
|
199
|
+
export function needsDirectionIsolation(kind) {
|
|
200
|
+
return ['code', 'url', 'path', 'identifier', 'timestamp', 'digest', 'version'].includes(kind);
|
|
201
|
+
}
|
|
202
|
+
//# sourceMappingURL=language.js.map
|
package/lib/paths.d.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping this machine's directory names out of everything that leaves it.
|
|
3
|
+
*
|
|
4
|
+
* A workspace was selected, and from then on `D:\Ws` appeared in the Context
|
|
5
|
+
* panel, in the session log, and in the text handed to the model — because a
|
|
6
|
+
* workspace is a directory and every layer below the UI quite reasonably
|
|
7
|
+
* carries the directory. None of those places needed the absolute path. The
|
|
8
|
+
* panel needed a name, the log needed something stable to group by, and the
|
|
9
|
+
* model needed to know which files exist relative to a root it never has to
|
|
10
|
+
* name.
|
|
11
|
+
*
|
|
12
|
+
* So this module converts a real path into a *logical* one: `<workspace>/src`
|
|
13
|
+
* rather than `D:\Ws\src`. The logical form is stable, comparable, and says
|
|
14
|
+
* everything the reader needs, and the real path stays on the Host where the
|
|
15
|
+
* filesystem actually is.
|
|
16
|
+
*
|
|
17
|
+
* **Structured, never blanket.** The one thing this must not do is replace
|
|
18
|
+
* substrings across arbitrary text: evidence content, a transcript, a captured
|
|
19
|
+
* page, a user's own message may legitimately contain a string that looks like
|
|
20
|
+
* a path, and rewriting it would corrupt the very record the product exists to
|
|
21
|
+
* preserve. Everything here therefore operates on a *named field* or on a
|
|
22
|
+
* bounded diagnostic string a caller has explicitly identified. There is no
|
|
23
|
+
* function in this file that takes a document and scrubs it.
|
|
24
|
+
*
|
|
25
|
+
* Windows makes the comparison harder than it looks, and each of these is
|
|
26
|
+
* handled rather than hoped about: drive letters differ in case (`D:\` and
|
|
27
|
+
* `d:\` are one directory), separators are mixed within a single string by the
|
|
28
|
+
* time Node and a shell have both touched it, UNC paths have a leading `\\`
|
|
29
|
+
* that is not a separator, and a prefix match without a boundary check makes
|
|
30
|
+
* `D:\Wsuite` look like it is inside `D:\Ws`.
|
|
31
|
+
*
|
|
32
|
+
* @module @deepwatch/dsh-contracts/paths
|
|
33
|
+
*/
|
|
34
|
+
/** A local root worth naming rather than printing. */
|
|
35
|
+
export type PathRootKind =
|
|
36
|
+
/** The directory the user chose to work in. */
|
|
37
|
+
'workspace'
|
|
38
|
+
/** The DeepWatch profile: runtime, receipts, profile state. */
|
|
39
|
+
| 'profile'
|
|
40
|
+
/** The DeepSeek Harness home inside that profile. */
|
|
41
|
+
| 'dsh-home'
|
|
42
|
+
/** A source checkout, present only on a developer's machine. */
|
|
43
|
+
| 'checkout'
|
|
44
|
+
/** The operating system user's home directory. */
|
|
45
|
+
| 'home'
|
|
46
|
+
/** The system temporary directory. */
|
|
47
|
+
| 'temp';
|
|
48
|
+
/** One root, and the label that replaces it. */
|
|
49
|
+
export interface PathRoot {
|
|
50
|
+
readonly kind: PathRootKind;
|
|
51
|
+
/** The real absolute path. Never rendered, never sent. */
|
|
52
|
+
readonly path: string;
|
|
53
|
+
/**
|
|
54
|
+
* What a reader sees instead, without the angle brackets.
|
|
55
|
+
*
|
|
56
|
+
* Defaults to the kind, so `workspace` renders as `<workspace>`. A caller
|
|
57
|
+
* with two workspaces open can pass `workspace:notes` and keep them apart
|
|
58
|
+
* without either becoming a directory name.
|
|
59
|
+
*/
|
|
60
|
+
readonly label?: string;
|
|
61
|
+
}
|
|
62
|
+
/** The roots a redaction is measured against, longest first. */
|
|
63
|
+
export type PathRoots = readonly PathRoot[];
|
|
64
|
+
/**
|
|
65
|
+
* Whether a string is an absolute local path in any of the three shapes.
|
|
66
|
+
*
|
|
67
|
+
* Used to decide whether a *field known to hold a path* needs converting. It
|
|
68
|
+
* is deliberately not used to scan prose.
|
|
69
|
+
*/
|
|
70
|
+
export declare function isAbsoluteLocalPath(value: string): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* One comparable spelling of a path.
|
|
73
|
+
*
|
|
74
|
+
* Separators become `/`, a trailing separator is dropped, and a drive letter
|
|
75
|
+
* is upper-cased — Windows treats `d:` and `D:` as one directory and a
|
|
76
|
+
* case-sensitive comparison would miss half the matches. The rest of the path
|
|
77
|
+
* keeps its case: on a case-sensitive filesystem `src` and `SRC` are two
|
|
78
|
+
* directories, and folding them would make the redaction wrong in the other
|
|
79
|
+
* direction.
|
|
80
|
+
*/
|
|
81
|
+
export declare function normalisePath(value: string): string;
|
|
82
|
+
/**
|
|
83
|
+
* Whether `candidate` is `root` or sits inside it.
|
|
84
|
+
*
|
|
85
|
+
* The separator check is the whole point: a bare `startsWith` reports that
|
|
86
|
+
* `D:\Wsuite` is inside `D:\Ws`, which would redact an unrelated
|
|
87
|
+
* directory and leave the reader with a path that never existed.
|
|
88
|
+
*/
|
|
89
|
+
export declare function isInsideRoot(root: string, candidate: string): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* One comparable spelling with `.` and `..` resolved away.
|
|
92
|
+
*
|
|
93
|
+
* Separate from {@link normalisePath} because the two answer different
|
|
94
|
+
* questions. Redaction asks "does this path start with a root I know", and a
|
|
95
|
+
* literal spelling is the right input for that. Containment asks "where does
|
|
96
|
+
* this path actually land", and there the literal spelling is the attack:
|
|
97
|
+
* `<workspace>/../elsewhere/notes.md` starts with the workspace and is not
|
|
98
|
+
* inside it. A containment check that skips this step reports the boundary
|
|
99
|
+
* holding while a call walks straight out of it.
|
|
100
|
+
*
|
|
101
|
+
* A `..` that would climb above the root is dropped rather than escaping into
|
|
102
|
+
* a relative prefix, so the result is always anchored where it started.
|
|
103
|
+
*/
|
|
104
|
+
export declare function resolveTraversal(value: string): string;
|
|
105
|
+
/**
|
|
106
|
+
* Whether a path lands inside a root once traversal is resolved.
|
|
107
|
+
*
|
|
108
|
+
* The containment question, and the one a boundary must ask.
|
|
109
|
+
* {@link isInsideRoot} is the redaction question and stays literal; this one
|
|
110
|
+
* resolves first, so `<root>/../elsewhere` is outside and `<root>/./a/../b` is
|
|
111
|
+
* inside.
|
|
112
|
+
*
|
|
113
|
+
* It cannot see a symlink or a junction — that needs the filesystem, and this
|
|
114
|
+
* package is shared with the browser. The Host resolves those before calling
|
|
115
|
+
* this; the two together are the boundary.
|
|
116
|
+
*/
|
|
117
|
+
export declare function containsPath(root: string, candidate: string): boolean;
|
|
118
|
+
/**
|
|
119
|
+
* The logical form of a path, or the path unchanged when no root contains it.
|
|
120
|
+
*
|
|
121
|
+
* Roots are tried longest-first so a workspace nested inside a profile is
|
|
122
|
+
* reported as `<workspace>/…` rather than `<profile>/workspace/…`: the more
|
|
123
|
+
* specific name is the more useful one.
|
|
124
|
+
*
|
|
125
|
+
* A path under no known root is returned as it came in. That is deliberate —
|
|
126
|
+
* silently mangling an unrecognised path would make a diagnostic unreadable —
|
|
127
|
+
* and it is why {@link assertNoLocalPath} exists for the surfaces where an
|
|
128
|
+
* unrecognised absolute path must be a failure rather than a passthrough.
|
|
129
|
+
*/
|
|
130
|
+
export declare function redactPath(value: string, roots: PathRoots): string;
|
|
131
|
+
/**
|
|
132
|
+
* The path relative to a root, for the places that want no label at all.
|
|
133
|
+
*
|
|
134
|
+
* What a model should be given: `src/index.ts`, not `<workspace>/src/index.ts`
|
|
135
|
+
* and certainly not `D:\Ws\src\index.ts`. Returns null when the path is not
|
|
136
|
+
* inside the root, so a caller cannot accidentally send an absolute path by
|
|
137
|
+
* treating a failed conversion as a success.
|
|
138
|
+
*/
|
|
139
|
+
export declare function relativeToRoot(value: string, root: string): string | null;
|
|
140
|
+
/**
|
|
141
|
+
* Redact only the named fields of a record.
|
|
142
|
+
*
|
|
143
|
+
* The safe shape of this operation. A caller says which keys hold paths, and
|
|
144
|
+
* nothing else in the object is examined — so a `content`, a `transcript` or a
|
|
145
|
+
* `message` beside them is carried through byte for byte.
|
|
146
|
+
*
|
|
147
|
+
* @param record - the object to copy.
|
|
148
|
+
* @param fields - the keys whose string values are paths.
|
|
149
|
+
* @param roots - the roots to measure against.
|
|
150
|
+
* @returns a shallow copy with those fields converted.
|
|
151
|
+
*/
|
|
152
|
+
export declare function redactFields<T extends Record<string, unknown>>(record: T, fields: readonly (keyof T & string)[], roots: PathRoots): T;
|
|
153
|
+
/**
|
|
154
|
+
* Redact one diagnostic string a caller has identified as path-bearing.
|
|
155
|
+
*
|
|
156
|
+
* Bounded on purpose: it rewrites only complete path tokens, delimited by
|
|
157
|
+
* whitespace, quotes or the common punctuation a path is wrapped in when it
|
|
158
|
+
* lands in a message. It is for a log line or an error detail whose shape the
|
|
159
|
+
* caller knows — never for evidence, a transcript, a captured document or
|
|
160
|
+
* anything a person wrote.
|
|
161
|
+
*/
|
|
162
|
+
export declare function redactDiagnosticText(text: string, roots: PathRoots): string;
|
|
163
|
+
/**
|
|
164
|
+
* Every absolute local path a string still contains.
|
|
165
|
+
*
|
|
166
|
+
* The test-facing half. A surface that must never carry one asserts this is
|
|
167
|
+
* empty, and gets the offending text back rather than a bare false.
|
|
168
|
+
*
|
|
169
|
+
* A path preceded by `//` in a URL is skipped: `https://host/home/x` names a
|
|
170
|
+
* server's route, not this machine's disk, and reporting it would be a false
|
|
171
|
+
* alarm in exactly the diagnostics a reader needs.
|
|
172
|
+
*
|
|
173
|
+
* The same false alarm reached the Windows branch by a subtler route, and cost
|
|
174
|
+
* more: a drive letter is one letter followed by a colon, and `https:` ends in
|
|
175
|
+
* exactly that shape. Every ordinary URL was therefore reported as a local
|
|
176
|
+
* path — `s://api.example.com` — so `assertNoLocalPath` threw on honest
|
|
177
|
+
* diagnostics like "failed to reach https://…", which is precisely the kind of
|
|
178
|
+
* guard somebody switches off. The lookbehind is what makes a drive letter a
|
|
179
|
+
* drive letter: nothing alphabetic before it, and no colon before a `//`.
|
|
180
|
+
*
|
|
181
|
+
* `file:///D:/Ws/x` still matches, and should: a file URL carries a real local
|
|
182
|
+
* path, and the `D:` in it is preceded by `/` rather than by a scheme.
|
|
183
|
+
*/
|
|
184
|
+
export declare function findAbsolutePaths(text: string): readonly string[];
|
|
185
|
+
/**
|
|
186
|
+
* Throw when a value that must be free of local paths is not.
|
|
187
|
+
*
|
|
188
|
+
* Used at the boundaries where a passthrough would be a leak rather than a
|
|
189
|
+
* convenience: a provider payload, an export, a rendered surface. The message
|
|
190
|
+
* names the field and the offending token so the failure is fixable, and it is
|
|
191
|
+
* a programming error rather than a runtime condition.
|
|
192
|
+
*/
|
|
193
|
+
export declare function assertNoLocalPath(where: string, value: string): void;
|
|
194
|
+
//# sourceMappingURL=paths.d.ts.map
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeping this machine's directory names out of everything that leaves it.
|
|
3
|
+
*
|
|
4
|
+
* A workspace was selected, and from then on `D:\Ws` appeared in the Context
|
|
5
|
+
* panel, in the session log, and in the text handed to the model — because a
|
|
6
|
+
* workspace is a directory and every layer below the UI quite reasonably
|
|
7
|
+
* carries the directory. None of those places needed the absolute path. The
|
|
8
|
+
* panel needed a name, the log needed something stable to group by, and the
|
|
9
|
+
* model needed to know which files exist relative to a root it never has to
|
|
10
|
+
* name.
|
|
11
|
+
*
|
|
12
|
+
* So this module converts a real path into a *logical* one: `<workspace>/src`
|
|
13
|
+
* rather than `D:\Ws\src`. The logical form is stable, comparable, and says
|
|
14
|
+
* everything the reader needs, and the real path stays on the Host where the
|
|
15
|
+
* filesystem actually is.
|
|
16
|
+
*
|
|
17
|
+
* **Structured, never blanket.** The one thing this must not do is replace
|
|
18
|
+
* substrings across arbitrary text: evidence content, a transcript, a captured
|
|
19
|
+
* page, a user's own message may legitimately contain a string that looks like
|
|
20
|
+
* a path, and rewriting it would corrupt the very record the product exists to
|
|
21
|
+
* preserve. Everything here therefore operates on a *named field* or on a
|
|
22
|
+
* bounded diagnostic string a caller has explicitly identified. There is no
|
|
23
|
+
* function in this file that takes a document and scrubs it.
|
|
24
|
+
*
|
|
25
|
+
* Windows makes the comparison harder than it looks, and each of these is
|
|
26
|
+
* handled rather than hoped about: drive letters differ in case (`D:\` and
|
|
27
|
+
* `d:\` are one directory), separators are mixed within a single string by the
|
|
28
|
+
* time Node and a shell have both touched it, UNC paths have a leading `\\`
|
|
29
|
+
* that is not a separator, and a prefix match without a boundary check makes
|
|
30
|
+
* `D:\Wsuite` look like it is inside `D:\Ws`.
|
|
31
|
+
*
|
|
32
|
+
* @module @deepwatch/dsh-contracts/paths
|
|
33
|
+
*/
|
|
34
|
+
/** Windows drive-absolute, e.g. `D:\Ws` or `d:/Ws`. */
|
|
35
|
+
const DRIVE_ABSOLUTE = /^[A-Za-z]:[\\/]/;
|
|
36
|
+
/** UNC, e.g. `\\server\share\dir`. */
|
|
37
|
+
const UNC_ABSOLUTE = /^[\\/]{2}[^\\/]+[\\/]+[^\\/]+/;
|
|
38
|
+
/**
|
|
39
|
+
* Whether a string is an absolute local path in any of the three shapes.
|
|
40
|
+
*
|
|
41
|
+
* Used to decide whether a *field known to hold a path* needs converting. It
|
|
42
|
+
* is deliberately not used to scan prose.
|
|
43
|
+
*/
|
|
44
|
+
export function isAbsoluteLocalPath(value) {
|
|
45
|
+
return DRIVE_ABSOLUTE.test(value) || UNC_ABSOLUTE.test(value) || value.startsWith('/');
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* One comparable spelling of a path.
|
|
49
|
+
*
|
|
50
|
+
* Separators become `/`, a trailing separator is dropped, and a drive letter
|
|
51
|
+
* is upper-cased — Windows treats `d:` and `D:` as one directory and a
|
|
52
|
+
* case-sensitive comparison would miss half the matches. The rest of the path
|
|
53
|
+
* keeps its case: on a case-sensitive filesystem `src` and `SRC` are two
|
|
54
|
+
* directories, and folding them would make the redaction wrong in the other
|
|
55
|
+
* direction.
|
|
56
|
+
*/
|
|
57
|
+
export function normalisePath(value) {
|
|
58
|
+
const slashed = value.replace(/\\/g, '/');
|
|
59
|
+
const drive = /^([A-Za-z]):\//.exec(slashed);
|
|
60
|
+
const cased = drive === null
|
|
61
|
+
? slashed
|
|
62
|
+
: `${drive[1]?.toUpperCase() ?? ''}:/${slashed.slice(3)}`;
|
|
63
|
+
return cased.length > 1 && cased.endsWith('/') ? cased.slice(0, -1) : cased;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Whether `candidate` is `root` or sits inside it.
|
|
67
|
+
*
|
|
68
|
+
* The separator check is the whole point: a bare `startsWith` reports that
|
|
69
|
+
* `D:\Wsuite` is inside `D:\Ws`, which would redact an unrelated
|
|
70
|
+
* directory and leave the reader with a path that never existed.
|
|
71
|
+
*/
|
|
72
|
+
export function isInsideRoot(root, candidate) {
|
|
73
|
+
const base = normalisePath(root);
|
|
74
|
+
const target = normalisePath(candidate);
|
|
75
|
+
if (base === target)
|
|
76
|
+
return true;
|
|
77
|
+
// Windows is case-insensitive about the whole path in practice, but only the
|
|
78
|
+
// drive is folded above; comparing the remainder exactly is the safe side of
|
|
79
|
+
// that trade — a missed redaction is caught by tests, a wrong one is not.
|
|
80
|
+
return target.startsWith(base.endsWith('/') ? base : `${base}/`);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* One comparable spelling with `.` and `..` resolved away.
|
|
84
|
+
*
|
|
85
|
+
* Separate from {@link normalisePath} because the two answer different
|
|
86
|
+
* questions. Redaction asks "does this path start with a root I know", and a
|
|
87
|
+
* literal spelling is the right input for that. Containment asks "where does
|
|
88
|
+
* this path actually land", and there the literal spelling is the attack:
|
|
89
|
+
* `<workspace>/../elsewhere/notes.md` starts with the workspace and is not
|
|
90
|
+
* inside it. A containment check that skips this step reports the boundary
|
|
91
|
+
* holding while a call walks straight out of it.
|
|
92
|
+
*
|
|
93
|
+
* A `..` that would climb above the root is dropped rather than escaping into
|
|
94
|
+
* a relative prefix, so the result is always anchored where it started.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveTraversal(value) {
|
|
97
|
+
const normalised = normalisePath(value);
|
|
98
|
+
const prefix = /^(?:[A-Z]:\/|\/\/[^/]+\/[^/]+\/|\/)/.exec(normalised)?.[0] ?? '';
|
|
99
|
+
const rest = normalised.slice(prefix.length);
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const segment of rest.split('/')) {
|
|
102
|
+
if (segment === '' || segment === '.')
|
|
103
|
+
continue;
|
|
104
|
+
if (segment === '..') {
|
|
105
|
+
out.pop();
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
out.push(segment);
|
|
109
|
+
}
|
|
110
|
+
const joined = `${prefix}${out.join('/')}`;
|
|
111
|
+
return joined.length > 1 && joined.endsWith('/') ? joined.slice(0, -1) : joined;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Whether a path lands inside a root once traversal is resolved.
|
|
115
|
+
*
|
|
116
|
+
* The containment question, and the one a boundary must ask.
|
|
117
|
+
* {@link isInsideRoot} is the redaction question and stays literal; this one
|
|
118
|
+
* resolves first, so `<root>/../elsewhere` is outside and `<root>/./a/../b` is
|
|
119
|
+
* inside.
|
|
120
|
+
*
|
|
121
|
+
* It cannot see a symlink or a junction — that needs the filesystem, and this
|
|
122
|
+
* package is shared with the browser. The Host resolves those before calling
|
|
123
|
+
* this; the two together are the boundary.
|
|
124
|
+
*/
|
|
125
|
+
export function containsPath(root, candidate) {
|
|
126
|
+
return isInsideRoot(resolveTraversal(root), resolveTraversal(candidate));
|
|
127
|
+
}
|
|
128
|
+
/** The label a root renders as, in angle brackets. */
|
|
129
|
+
function labelOf(root) {
|
|
130
|
+
return `<${root.label ?? root.kind}>`;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The logical form of a path, or the path unchanged when no root contains it.
|
|
134
|
+
*
|
|
135
|
+
* Roots are tried longest-first so a workspace nested inside a profile is
|
|
136
|
+
* reported as `<workspace>/…` rather than `<profile>/workspace/…`: the more
|
|
137
|
+
* specific name is the more useful one.
|
|
138
|
+
*
|
|
139
|
+
* A path under no known root is returned as it came in. That is deliberate —
|
|
140
|
+
* silently mangling an unrecognised path would make a diagnostic unreadable —
|
|
141
|
+
* and it is why {@link assertNoLocalPath} exists for the surfaces where an
|
|
142
|
+
* unrecognised absolute path must be a failure rather than a passthrough.
|
|
143
|
+
*/
|
|
144
|
+
export function redactPath(value, roots) {
|
|
145
|
+
if (value === '')
|
|
146
|
+
return value;
|
|
147
|
+
const ordered = [...roots].sort((a, b) => normalisePath(b.path).length - normalisePath(a.path).length);
|
|
148
|
+
for (const root of ordered) {
|
|
149
|
+
if (!isInsideRoot(root.path, value))
|
|
150
|
+
continue;
|
|
151
|
+
const base = normalisePath(root.path);
|
|
152
|
+
const target = normalisePath(value);
|
|
153
|
+
const rest = target.slice(base.length).replace(/^\//, '');
|
|
154
|
+
return rest === '' ? labelOf(root) : `${labelOf(root)}/${rest}`;
|
|
155
|
+
}
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The path relative to a root, for the places that want no label at all.
|
|
160
|
+
*
|
|
161
|
+
* What a model should be given: `src/index.ts`, not `<workspace>/src/index.ts`
|
|
162
|
+
* and certainly not `D:\Ws\src\index.ts`. Returns null when the path is not
|
|
163
|
+
* inside the root, so a caller cannot accidentally send an absolute path by
|
|
164
|
+
* treating a failed conversion as a success.
|
|
165
|
+
*/
|
|
166
|
+
export function relativeToRoot(value, root) {
|
|
167
|
+
if (!isInsideRoot(root, value))
|
|
168
|
+
return null;
|
|
169
|
+
const base = normalisePath(root);
|
|
170
|
+
const target = normalisePath(value);
|
|
171
|
+
return target === base ? '.' : target.slice(base.length).replace(/^\//, '');
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Redact only the named fields of a record.
|
|
175
|
+
*
|
|
176
|
+
* The safe shape of this operation. A caller says which keys hold paths, and
|
|
177
|
+
* nothing else in the object is examined — so a `content`, a `transcript` or a
|
|
178
|
+
* `message` beside them is carried through byte for byte.
|
|
179
|
+
*
|
|
180
|
+
* @param record - the object to copy.
|
|
181
|
+
* @param fields - the keys whose string values are paths.
|
|
182
|
+
* @param roots - the roots to measure against.
|
|
183
|
+
* @returns a shallow copy with those fields converted.
|
|
184
|
+
*/
|
|
185
|
+
export function redactFields(record, fields, roots) {
|
|
186
|
+
const out = { ...record };
|
|
187
|
+
for (const field of fields) {
|
|
188
|
+
const value = out[field];
|
|
189
|
+
if (typeof value === 'string')
|
|
190
|
+
out[field] = redactPath(value, roots);
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Redact one diagnostic string a caller has identified as path-bearing.
|
|
196
|
+
*
|
|
197
|
+
* Bounded on purpose: it rewrites only complete path tokens, delimited by
|
|
198
|
+
* whitespace, quotes or the common punctuation a path is wrapped in when it
|
|
199
|
+
* lands in a message. It is for a log line or an error detail whose shape the
|
|
200
|
+
* caller knows — never for evidence, a transcript, a captured document or
|
|
201
|
+
* anything a person wrote.
|
|
202
|
+
*/
|
|
203
|
+
export function redactDiagnosticText(text, roots) {
|
|
204
|
+
let out = text;
|
|
205
|
+
const ordered = [...roots].sort((a, b) => normalisePath(b.path).length - normalisePath(a.path).length);
|
|
206
|
+
for (const root of ordered) {
|
|
207
|
+
const base = normalisePath(root.path);
|
|
208
|
+
// Both spellings, because a path reaches a message through Node (forward
|
|
209
|
+
// slashes) and through a shell or a Windows API (backslashes) equally often.
|
|
210
|
+
for (const spelling of [base, base.replace(/\//g, '\\')]) {
|
|
211
|
+
const pattern = new RegExp(
|
|
212
|
+
// portability-ok: a character class matching either separator, not a path
|
|
213
|
+
// this module builds. Reading paths written on another platform is the job.
|
|
214
|
+
`${escapeForPattern(spelling)}(?![A-Za-z0-9_.-])([\\\\/][^\\s"'\`,;)\\]]*)?`, 'g'); // portability-ok
|
|
215
|
+
out = out.replace(pattern, (_match, rest) => rest === undefined || rest === ''
|
|
216
|
+
? labelOf(root)
|
|
217
|
+
: `${labelOf(root)}${rest.replace(/\\/g, '/')}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
/** Escape a literal for use inside a regular expression. */
|
|
223
|
+
function escapeForPattern(value) {
|
|
224
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* POSIX directories that hold a person's machine rather than a server's URL
|
|
228
|
+
* space.
|
|
229
|
+
*
|
|
230
|
+
* A bare leading `/` cannot be the test: `/api/v1/chat` is a URL path and
|
|
231
|
+
* appears in every honest diagnostic this product writes, so matching it would
|
|
232
|
+
* make the assertion below fire constantly and be switched off. These are the
|
|
233
|
+
* roots a real leak comes from.
|
|
234
|
+
*/
|
|
235
|
+
const POSIX_LOCAL_ROOTS = ['home', 'Users', 'var', 'tmp', 'root', 'mnt', 'media', 'opt', 'private'];
|
|
236
|
+
/**
|
|
237
|
+
* Every absolute local path a string still contains.
|
|
238
|
+
*
|
|
239
|
+
* The test-facing half. A surface that must never carry one asserts this is
|
|
240
|
+
* empty, and gets the offending text back rather than a bare false.
|
|
241
|
+
*
|
|
242
|
+
* A path preceded by `//` in a URL is skipped: `https://host/home/x` names a
|
|
243
|
+
* server's route, not this machine's disk, and reporting it would be a false
|
|
244
|
+
* alarm in exactly the diagnostics a reader needs.
|
|
245
|
+
*
|
|
246
|
+
* The same false alarm reached the Windows branch by a subtler route, and cost
|
|
247
|
+
* more: a drive letter is one letter followed by a colon, and `https:` ends in
|
|
248
|
+
* exactly that shape. Every ordinary URL was therefore reported as a local
|
|
249
|
+
* path — `s://api.example.com` — so `assertNoLocalPath` threw on honest
|
|
250
|
+
* diagnostics like "failed to reach https://…", which is precisely the kind of
|
|
251
|
+
* guard somebody switches off. The lookbehind is what makes a drive letter a
|
|
252
|
+
* drive letter: nothing alphabetic before it, and no colon before a `//`.
|
|
253
|
+
*
|
|
254
|
+
* `file:///D:/Ws/x` still matches, and should: a file URL carries a real local
|
|
255
|
+
* path, and the `D:` in it is preceded by `/` rather than by a scheme.
|
|
256
|
+
*/
|
|
257
|
+
export function findAbsolutePaths(text) {
|
|
258
|
+
const found = [];
|
|
259
|
+
const windows = /(?<![A-Za-z:])(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/\s]+[\\/])[^\s"'`,;)\]]*/g;
|
|
260
|
+
for (const match of text.matchAll(windows))
|
|
261
|
+
found.push(match[0]);
|
|
262
|
+
// portability-ok: the escaped class is regex syntax, not a path separator.
|
|
263
|
+
const posix = new RegExp(`(^|[^A-Za-z0-9_:/])(/(?:${POSIX_LOCAL_ROOTS.join('|')})/[^\\s"'\`,;)\\]]*)`, 'g'); // portability-ok
|
|
264
|
+
for (const match of text.matchAll(posix)) {
|
|
265
|
+
const value = match[2];
|
|
266
|
+
if (value !== undefined)
|
|
267
|
+
found.push(value);
|
|
268
|
+
}
|
|
269
|
+
return found;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Throw when a value that must be free of local paths is not.
|
|
273
|
+
*
|
|
274
|
+
* Used at the boundaries where a passthrough would be a leak rather than a
|
|
275
|
+
* convenience: a provider payload, an export, a rendered surface. The message
|
|
276
|
+
* names the field and the offending token so the failure is fixable, and it is
|
|
277
|
+
* a programming error rather than a runtime condition.
|
|
278
|
+
*/
|
|
279
|
+
export function assertNoLocalPath(where, value) {
|
|
280
|
+
const found = findAbsolutePaths(value);
|
|
281
|
+
if (found.length === 0)
|
|
282
|
+
return;
|
|
283
|
+
throw new Error(`${where} carries ${String(found.length)} absolute local path(s), starting with `
|
|
284
|
+
+ `${String(found[0])}. Convert it with redactPath or relativeToRoot first.`);
|
|
285
|
+
}
|
|
286
|
+
//# sourceMappingURL=paths.js.map
|