sdlc-agent-4-enterprise-server 1.55.7 → 1.55.8
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 +1 -1
- package/dist/config/ContentProxy.d.ts +20 -3
- package/dist/config/ContentProxy.js +43 -17
- package/dist/config/ContentProxy.js.map +1 -1
- package/dist/modules/orchestration/content-proxy/artifact-source.d.ts +8 -0
- package/dist/modules/orchestration/content-proxy/artifact-source.js +52 -7
- package/dist/modules/orchestration/content-proxy/artifact-source.js.map +1 -1
- package/package.json +1 -1
- package/src/config/ContentProxy.ts +43 -15
- package/src/modules/orchestration/__tests__/McpClientManager.content-proxy.test.ts +6 -1
- package/src/modules/orchestration/content-proxy/__tests__/content-proxy.test.ts +6 -1
- package/src/modules/orchestration/content-proxy/artifact-source.ts +54 -7
- package/tests/integration/sandbox.it.test.ts +6 -3
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
8
|
<p align="center">
|
|
9
|
-
<img src="https://img.shields.io/badge/version-1.55.
|
|
9
|
+
<img src="https://img.shields.io/badge/version-1.55.8-blue?style=for-the-badge" alt="Version">
|
|
10
10
|
<img src="https://img.shields.io/badge/tools-66+-teal?style=for-the-badge" alt="Tools">
|
|
11
11
|
<img src="https://img.shields.io/badge/node-%3E%3D18-green?style=for-the-badge" alt="Node">
|
|
12
12
|
</p>
|
|
@@ -30,9 +30,26 @@ export interface ContentProxyOverride {
|
|
|
30
30
|
/** Disable the adapter entirely for this tool. */
|
|
31
31
|
enabled?: boolean;
|
|
32
32
|
}
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Artifact output roots — the dedicated sinks for orphaned artifact files
|
|
35
|
+
* (SA4E-380 F-01/F-05). Shared single source for OI-14 (immediate cleanup +
|
|
36
|
+
* read confinement) and OI-15 (periodic sweep). The raw OS temp root and the
|
|
37
|
+
* agent workspace are NEVER roots.
|
|
38
|
+
*
|
|
39
|
+
* Resolution order:
|
|
40
|
+
* 1. `ARTIFACT_OUTPUT_ROOTS` env override — `path.delimiter`-separated list
|
|
41
|
+
* (operator trust; see SA-08 for the recommended defense-in-depth checks).
|
|
42
|
+
* 2. Defaults: the dedicated `%TEMP%/sa4e-artifacts` sink, the workspace
|
|
43
|
+
* `tmp/artifacts` directory, and the markdown-exporter child's own
|
|
44
|
+
* artifact home (F-01: without it every local-path read fails confinement).
|
|
45
|
+
*/
|
|
46
|
+
export declare function artifactOutputRoots(env?: NodeJS.ProcessEnv): readonly string[];
|
|
47
|
+
/**
|
|
48
|
+
* F-01 containment: is `candidate` (already realpath-canonicalised by the
|
|
49
|
+
* caller) located inside `root`? Compares with `path.relative` — case-folded
|
|
50
|
+
* on win32, cross-device resolves to an absolute path → rejected. Separator
|
|
51
|
+
* boundary is honoured (`<root>-evil` is NOT inside `<root>`).
|
|
52
|
+
*/
|
|
36
53
|
export declare function isPathWithinRoot(root: string, candidate: string): boolean;
|
|
37
54
|
/** Per-tool escape hatch for tools that defy the naming/description convention. */
|
|
38
55
|
export declare const CONTENT_PROXY_OVERRIDES: Record<string, ContentProxyOverride>;
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Overrides exist only for tools that do not follow the convention.
|
|
13
13
|
*/
|
|
14
|
-
import * as
|
|
14
|
+
import * as os from 'os';
|
|
15
|
+
import * as path from 'path';
|
|
15
16
|
/** Tool-name prefixes whose result is a file written by the child server. */
|
|
16
17
|
export const FILE_TOOL_NAME_PREFIXES = ['export_', 'render_', 'generate_'];
|
|
17
18
|
/** Description fallback: "Convert ... to PDF/DOCX/…" (target must be a file format). */
|
|
@@ -32,24 +33,49 @@ export const CONTENT_PARAM_ALIASES = [
|
|
|
32
33
|
export const NON_CONTENT_PARAM = /^(content_base64|.*_base64|file_path|.*_path|path|url|uri|dir|.*_id|id|name|.*_name|key|.*_key|token|secret|password|mime|.*_type|type|format|mode|encoding)$/i;
|
|
33
34
|
/** Maximum artifact size inlined as base64 (mirrors the extension pre-read cap). */
|
|
34
35
|
export const MAX_ARTIFACT_BYTES = 25 * 1024 * 1024;
|
|
35
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Artifact output roots — the dedicated sinks for orphaned artifact files
|
|
38
|
+
* (SA4E-380 F-01/F-05). Shared single source for OI-14 (immediate cleanup +
|
|
39
|
+
* read confinement) and OI-15 (periodic sweep). The raw OS temp root and the
|
|
40
|
+
* agent workspace are NEVER roots.
|
|
41
|
+
*
|
|
42
|
+
* Resolution order:
|
|
43
|
+
* 1. `ARTIFACT_OUTPUT_ROOTS` env override — `path.delimiter`-separated list
|
|
44
|
+
* (operator trust; see SA-08 for the recommended defense-in-depth checks).
|
|
45
|
+
* 2. Defaults: the dedicated `%TEMP%/sa4e-artifacts` sink, the workspace
|
|
46
|
+
* `tmp/artifacts` directory, and the markdown-exporter child's own
|
|
47
|
+
* artifact home (F-01: without it every local-path read fails confinement).
|
|
48
|
+
*/
|
|
49
|
+
export function artifactOutputRoots(env = process.env) {
|
|
50
|
+
const override = parseArtifactRootsEnv(env.ARTIFACT_OUTPUT_ROOTS);
|
|
51
|
+
if (override)
|
|
52
|
+
return override;
|
|
53
|
+
const workspace = path.resolve(process.cwd());
|
|
54
|
+
const childHome = process.platform === 'win32'
|
|
55
|
+
? path.join(process.env.LOCALAPPDATA ?? '', 'markdown-exporter-mcp', 'artifacts')
|
|
56
|
+
: path.join(process.env.HOME ?? os.homedir(), '.cache', 'markdown-exporter-mcp', 'artifacts');
|
|
57
|
+
return [
|
|
58
|
+
path.join(os.tmpdir(), 'sa4e-artifacts'),
|
|
59
|
+
path.join(workspace, 'tmp', 'artifacts'),
|
|
60
|
+
path.resolve(childHome),
|
|
61
|
+
];
|
|
43
62
|
}
|
|
44
|
-
/**
|
|
63
|
+
/** SA-08 surface: parse the env override; empty/garbage → fall back to defaults. */
|
|
64
|
+
function parseArtifactRootsEnv(raw) {
|
|
65
|
+
if (!raw)
|
|
66
|
+
return undefined;
|
|
67
|
+
const roots = raw.split(path.delimiter).map((s) => s.trim()).filter(Boolean);
|
|
68
|
+
return roots.length ? roots : undefined;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* F-01 containment: is `candidate` (already realpath-canonicalised by the
|
|
72
|
+
* caller) located inside `root`? Compares with `path.relative` — case-folded
|
|
73
|
+
* on win32, cross-device resolves to an absolute path → rejected. Separator
|
|
74
|
+
* boundary is honoured (`<root>-evil` is NOT inside `<root>`).
|
|
75
|
+
*/
|
|
45
76
|
export function isPathWithinRoot(root, candidate) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return artifactOutputRoots().some((r) => real.startsWith(r));
|
|
49
|
-
}
|
|
50
|
-
catch {
|
|
51
|
-
return false;
|
|
52
|
-
}
|
|
77
|
+
const rel = path.relative(path.resolve(root), path.resolve(candidate));
|
|
78
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
53
79
|
}
|
|
54
80
|
/** Per-tool escape hatch for tools that defy the naming/description convention. */
|
|
55
81
|
export const CONTENT_PROXY_OVERRIDES = {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ContentProxy.js","sourceRoot":"","sources":["../../src/config/ContentProxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,6EAA6E;AAC7E,MAAM,CAAC,MAAM,uBAAuB,GAAsB,CAAC,SAAS,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC;AAE9F,wFAAwF;AACxF,MAAM,CAAC,MAAM,uBAAuB,GAAG,gEAAgE,CAAC;AAExG,qEAAqE;AACrE,MAAM,CAAC,MAAM,qBAAqB,GAAsB;IACtD,UAAU;IACV,kBAAkB;IAClB,SAAS;IACT,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,QAAQ;IACR,UAAU;CACX,CAAC;AAEF,6EAA6E;AAC7E,MAAM,CAAC,MAAM,iBAAiB,GAC5B,gKAAgK,CAAC;AAEnK,oFAAoF;AACpF,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC;AAYnD
|
|
1
|
+
{"version":3,"file":"ContentProxy.js","sourceRoot":"","sources":["../../src/config/ContentProxy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAC7B,6EAA6E;AAC7E,MAAM,CAAC,MAAM,uBAAuB,GAAsB,CAAC,SAAS,EAAE,SAAS,EAAE,WAAW,CAAC,CAAC;AAE9F,wFAAwF;AACxF,MAAM,CAAC,MAAM,uBAAuB,GAAG,gEAAgE,CAAC;AAExG,qEAAqE;AACrE,MAAM,CAAC,MAAM,qBAAqB,GAAsB;IACtD,UAAU;IACV,kBAAkB;IAClB,SAAS;IACT,MAAM;IACN,KAAK;IACL,MAAM;IACN,MAAM;IACN,QAAQ;IACR,UAAU;CACX,CAAC;AAEF,6EAA6E;AAC7E,MAAM,CAAC,MAAM,iBAAiB,GAC5B,gKAAgK,CAAC;AAEnK,oFAAoF;AACpF,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC;AAYnD;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,mBAAmB,CAAC,MAAyB,OAAO,CAAC,GAAG;IACtE,MAAM,QAAQ,GAAG,qBAAqB,CAAC,GAAG,CAAC,qBAAqB,CAAC,CAAC;IAClE,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAC;IAC9B,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IAC9C,MAAM,SAAS,GACb,OAAO,CAAC,QAAQ,KAAK,OAAO;QAC1B,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,YAAY,IAAI,EAAE,EAAE,uBAAuB,EAAE,WAAW,CAAC;QACjF,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,uBAAuB,EAAE,WAAW,CAAC,CAAC;IAClG,OAAO;QACL,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE,gBAAgB,CAAC;QACxC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,KAAK,EAAE,WAAW,CAAC;QACxC,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC;KACxB,CAAC;AACJ,CAAC;AAED,oFAAoF;AACpF,SAAS,qBAAqB,CAAC,GAAuB;IACpD,IAAI,CAAC,GAAG;QAAE,OAAO,SAAS,CAAC;IAC3B,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAC7E,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AAC1C,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAY,EAAE,SAAiB;IAC9D,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC;IACvE,OAAO,GAAG,KAAK,EAAE,IAAI,CAAC,GAAG,KAAK,IAAI,IAAI,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC;AACnG,CAAC;AAED,mFAAmF;AACnF,MAAM,CAAC,MAAM,uBAAuB,GAAyC,EAAE,CAAC"}
|
|
@@ -11,11 +11,19 @@
|
|
|
11
11
|
* live on another machine. That case is reported as a tool error by the caller,
|
|
12
12
|
* never silently downgraded to a path.
|
|
13
13
|
*/
|
|
14
|
+
import * as fs from 'fs';
|
|
14
15
|
export interface ArtifactDeps {
|
|
15
16
|
/** Read a resource from the child server (works across hosts). */
|
|
16
17
|
readResource?: (uri: string) => Promise<Buffer | null>;
|
|
17
18
|
/** Fetch an http(s) URL. Overridable for tests; defaults to global fetch. */
|
|
18
19
|
fetchUrl?: (url: string) => Promise<Buffer | null>;
|
|
20
|
+
/**
|
|
21
|
+
* OI-14 local-read cleanup hook. Receives the absolute path and the
|
|
22
|
+
* pre-read stat; the default (no hook) deletes the artifact after its
|
|
23
|
+
* bytes are inlined. Only local reads invoke it — resource/URL/blob
|
|
24
|
+
* resolutions never do (UT-46).
|
|
25
|
+
*/
|
|
26
|
+
cleanup?: (filePath: string, stat: fs.Stats) => void;
|
|
19
27
|
}
|
|
20
28
|
export interface FetchedArtifact {
|
|
21
29
|
buffer: Buffer;
|
|
@@ -15,6 +15,7 @@ import * as fs from 'fs';
|
|
|
15
15
|
import * as path from 'path';
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
17
17
|
import { MAX_ARTIFACT_BYTES } from '../../../config/ContentProxy.js';
|
|
18
|
+
import { isWithinArtifactRoots } from '../artifact-cleanup/artifact-roots.js';
|
|
18
19
|
import { nameFromLocation, nameFromMime, safeName } from './artifact-name.js';
|
|
19
20
|
/** Resolve one `artifacts[]` entry to bytes, or null when nothing is readable. */
|
|
20
21
|
export async function resolveArtifact(artifact, deps) {
|
|
@@ -67,7 +68,7 @@ export async function readLocation(location, deps) {
|
|
|
67
68
|
return (deps.fetchUrl ?? fetchHttp)(location);
|
|
68
69
|
const fileUri = location.startsWith('file:') ? location : toFileUri(location);
|
|
69
70
|
if (location.startsWith('file:')) {
|
|
70
|
-
const local = readLocalFileUri(location);
|
|
71
|
+
const local = readLocalFileUri(location, deps);
|
|
71
72
|
if (local)
|
|
72
73
|
return local;
|
|
73
74
|
return deps.readResource ? deps.readResource(location) : null;
|
|
@@ -76,7 +77,7 @@ export async function readLocation(location, deps) {
|
|
|
76
77
|
// Any other scheme (artifact://, mcp://, …) is server-owned — ask the server.
|
|
77
78
|
return deps.readResource ? deps.readResource(location) : null;
|
|
78
79
|
}
|
|
79
|
-
const local = readLocalFile(path.resolve(location));
|
|
80
|
+
const local = readLocalFile(path.resolve(location), deps);
|
|
80
81
|
if (local)
|
|
81
82
|
return local;
|
|
82
83
|
return deps.readResource && fileUri ? deps.readResource(fileUri) : null;
|
|
@@ -94,27 +95,71 @@ function fromBase64(value, name, mime) {
|
|
|
94
95
|
return null;
|
|
95
96
|
return { buffer, file_name: name ?? nameFromMime(mime), mime_type: mime };
|
|
96
97
|
}
|
|
97
|
-
/**
|
|
98
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Read a file on the backend host: regular file only, size-capped, no symlinks.
|
|
100
|
+
*
|
|
101
|
+
* SA4E-380 F-01 (binding): the path's real location (symlinks/junctions/`..`
|
|
102
|
+
* resolved) must fall inside the dedicated ARTIFACT_OUTPUT_ROOTS BEFORE any
|
|
103
|
+
* filesystem access — an out-of-root path is never read and never deleted.
|
|
104
|
+
*
|
|
105
|
+
* SA4E-380 OI-14: once the bytes are inlined the local artifact is garbage —
|
|
106
|
+
* the cleanup hook (default: delete with F-06 stat-identity re-verification)
|
|
107
|
+
* runs after the successful read. A failed read never deletes anything.
|
|
108
|
+
*/
|
|
109
|
+
function readLocalFile(filePath, deps) {
|
|
110
|
+
if (!isWithinArtifactRoots(filePath))
|
|
111
|
+
return null;
|
|
99
112
|
try {
|
|
100
113
|
const stat = fs.lstatSync(filePath);
|
|
101
114
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_ARTIFACT_BYTES)
|
|
102
115
|
return null;
|
|
103
116
|
const buffer = fs.readFileSync(filePath);
|
|
104
|
-
|
|
117
|
+
if (buffer.length > MAX_ARTIFACT_BYTES)
|
|
118
|
+
return null;
|
|
119
|
+
try {
|
|
120
|
+
(deps.cleanup ?? deleteLocalArtifact)(filePath, stat);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// Cleanup failure never fails the tool call (UT-45) — bytes are safe.
|
|
124
|
+
console.warn(`[artifact-source] cleanup hook failed for '${path.basename(filePath)}'`);
|
|
125
|
+
}
|
|
126
|
+
return buffer;
|
|
105
127
|
}
|
|
106
128
|
catch {
|
|
107
129
|
return null;
|
|
108
130
|
}
|
|
109
131
|
}
|
|
110
|
-
function readLocalFileUri(uri) {
|
|
132
|
+
function readLocalFileUri(uri, deps) {
|
|
111
133
|
try {
|
|
112
|
-
return readLocalFile(fileURLToPath(uri));
|
|
134
|
+
return readLocalFile(fileURLToPath(uri), deps);
|
|
113
135
|
}
|
|
114
136
|
catch {
|
|
115
137
|
return null;
|
|
116
138
|
}
|
|
117
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* Default OI-14 cleanup: delete the artifact whose bytes were just inlined.
|
|
142
|
+
* F-06 TOCTOU guard — re-stat and verify identity (ino/size/mtimeMs) before
|
|
143
|
+
* unlink so a path swapped between read and delete is never removed.
|
|
144
|
+
*/
|
|
145
|
+
function deleteLocalArtifact(filePath, before) {
|
|
146
|
+
let now;
|
|
147
|
+
try {
|
|
148
|
+
now = fs.lstatSync(filePath);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return; // already gone — nothing to delete
|
|
152
|
+
}
|
|
153
|
+
if (now.ino !== before.ino || now.size !== before.size || now.mtimeMs !== before.mtimeMs)
|
|
154
|
+
return;
|
|
155
|
+
try {
|
|
156
|
+
fs.unlinkSync(filePath);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// I-3: basename only — never leak host paths into logs.
|
|
160
|
+
console.warn(`[artifact-source] could not delete local artifact '${path.basename(filePath)}'`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
118
163
|
/** Cross-platform file:// URI — a POSIX path stays POSIX even on a Windows host. */
|
|
119
164
|
function toFileUri(location) {
|
|
120
165
|
try {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"artifact-source.js","sourceRoot":"","sources":["../../../../src/modules/orchestration/content-proxy/artifact-source.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,MAAM,IAAI,CAAC;AACzB,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAC7B,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,KAAK,CAAC;AACnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,iCAAiC,CAAC;AACrE,OAAO,EAAE,gBAAgB,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"artifact-source.js","sourceRoot":"","sources":["../../../../src/modules/orchestration/content-proxy/artifact-source.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* Overrides exist only for tools that do not follow the convention.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import * as
|
|
15
|
+
import * as os from 'os';
|
|
16
|
+
import * as path from 'path';
|
|
16
17
|
/** Tool-name prefixes whose result is a file written by the child server. */
|
|
17
18
|
export const FILE_TOOL_NAME_PREFIXES: readonly string[] = ['export_', 'render_', 'generate_'];
|
|
18
19
|
|
|
@@ -49,23 +50,50 @@ export interface ContentProxyOverride {
|
|
|
49
50
|
enabled?: boolean;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Artifact output roots — the dedicated sinks for orphaned artifact files
|
|
55
|
+
* (SA4E-380 F-01/F-05). Shared single source for OI-14 (immediate cleanup +
|
|
56
|
+
* read confinement) and OI-15 (periodic sweep). The raw OS temp root and the
|
|
57
|
+
* agent workspace are NEVER roots.
|
|
58
|
+
*
|
|
59
|
+
* Resolution order:
|
|
60
|
+
* 1. `ARTIFACT_OUTPUT_ROOTS` env override — `path.delimiter`-separated list
|
|
61
|
+
* (operator trust; see SA-08 for the recommended defense-in-depth checks).
|
|
62
|
+
* 2. Defaults: the dedicated `%TEMP%/sa4e-artifacts` sink, the workspace
|
|
63
|
+
* `tmp/artifacts` directory, and the markdown-exporter child's own
|
|
64
|
+
* artifact home (F-01: without it every local-path read fails confinement).
|
|
65
|
+
*/
|
|
66
|
+
export function artifactOutputRoots(env: NodeJS.ProcessEnv = process.env): readonly string[] {
|
|
67
|
+
const override = parseArtifactRootsEnv(env.ARTIFACT_OUTPUT_ROOTS);
|
|
68
|
+
if (override) return override;
|
|
69
|
+
const workspace = path.resolve(process.cwd());
|
|
70
|
+
const childHome =
|
|
71
|
+
process.platform === 'win32'
|
|
72
|
+
? path.join(process.env.LOCALAPPDATA ?? '', 'markdown-exporter-mcp', 'artifacts')
|
|
73
|
+
: path.join(process.env.HOME ?? os.homedir(), '.cache', 'markdown-exporter-mcp', 'artifacts');
|
|
74
|
+
return [
|
|
75
|
+
path.join(os.tmpdir(), 'sa4e-artifacts'),
|
|
76
|
+
path.join(workspace, 'tmp', 'artifacts'),
|
|
77
|
+
path.resolve(childHome),
|
|
78
|
+
];
|
|
59
79
|
}
|
|
60
80
|
|
|
61
|
-
/**
|
|
81
|
+
/** SA-08 surface: parse the env override; empty/garbage → fall back to defaults. */
|
|
82
|
+
function parseArtifactRootsEnv(raw: string | undefined): readonly string[] | undefined {
|
|
83
|
+
if (!raw) return undefined;
|
|
84
|
+
const roots = raw.split(path.delimiter).map((s) => s.trim()).filter(Boolean);
|
|
85
|
+
return roots.length ? roots : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* F-01 containment: is `candidate` (already realpath-canonicalised by the
|
|
90
|
+
* caller) located inside `root`? Compares with `path.relative` — case-folded
|
|
91
|
+
* on win32, cross-device resolves to an absolute path → rejected. Separator
|
|
92
|
+
* boundary is honoured (`<root>-evil` is NOT inside `<root>`).
|
|
93
|
+
*/
|
|
62
94
|
export function isPathWithinRoot(root: string, candidate: string): boolean {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return artifactOutputRoots().some((r) => real.startsWith(r));
|
|
66
|
-
} catch {
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
95
|
+
const rel = path.relative(path.resolve(root), path.resolve(candidate));
|
|
96
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
69
97
|
}
|
|
70
98
|
|
|
71
99
|
/** Per-tool escape hatch for tools that defy the naming/description convention. */
|
|
@@ -11,7 +11,12 @@ import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
|
11
11
|
import { McpClientManager } from '../McpClientManager.js';
|
|
12
12
|
import { silentLogger } from '../../../__tests__/sa4e-testkit.js';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// SA4E-380 F-01: a local artifact is only inlined when it lives inside a
|
|
15
|
+
// dedicated artifact root. The default root is `<os.tmpdir()>/sa4e-artifacts`,
|
|
16
|
+
// so the DOCX fixture must be created there for the containment check to pass.
|
|
17
|
+
const ARTIFACT_ROOT = path.join(os.tmpdir(), 'sa4e-artifacts');
|
|
18
|
+
fs.mkdirSync(ARTIFACT_ROOT, { recursive: true });
|
|
19
|
+
const TMP = fs.mkdtempSync(path.join(ARTIFACT_ROOT, 'content-proxy-mgr-'));
|
|
15
20
|
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
|
16
21
|
|
|
17
22
|
const EXPORT_DOCX_TOOL = {
|
|
@@ -13,7 +13,12 @@ import { adaptInvocation } from '../invocation-adapter.js';
|
|
|
13
13
|
import { adaptResult } from '../result-adapter.js';
|
|
14
14
|
import type { ToolDefinition } from '../../../../types/tool.js';
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
// SA4E-380 F-01: local artifacts are only inlined when they live inside a
|
|
17
|
+
// dedicated artifact root. The default root is `<os.tmpdir()>/sa4e-artifacts`,
|
|
18
|
+
// so fixtures must be created there for the containment check to pass.
|
|
19
|
+
const ARTIFACT_ROOT = path.join(os.tmpdir(), 'sa4e-artifacts');
|
|
20
|
+
fs.mkdirSync(ARTIFACT_ROOT, { recursive: true });
|
|
21
|
+
const TMP = fs.mkdtempSync(path.join(ARTIFACT_ROOT, 'content-proxy-'));
|
|
17
22
|
afterAll(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
|
18
23
|
|
|
19
24
|
const EXPORT_DOCX: ToolDefinition = {
|
|
@@ -16,6 +16,7 @@ import * as fs from 'fs';
|
|
|
16
16
|
import * as path from 'path';
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
18
18
|
import { MAX_ARTIFACT_BYTES } from '../../../config/ContentProxy.js';
|
|
19
|
+
import { isWithinArtifactRoots } from '../artifact-cleanup/artifact-roots.js';
|
|
19
20
|
import { nameFromLocation, nameFromMime, safeName } from './artifact-name.js';
|
|
20
21
|
|
|
21
22
|
export interface ArtifactDeps {
|
|
@@ -23,6 +24,13 @@ export interface ArtifactDeps {
|
|
|
23
24
|
readResource?: (uri: string) => Promise<Buffer | null>;
|
|
24
25
|
/** Fetch an http(s) URL. Overridable for tests; defaults to global fetch. */
|
|
25
26
|
fetchUrl?: (url: string) => Promise<Buffer | null>;
|
|
27
|
+
/**
|
|
28
|
+
* OI-14 local-read cleanup hook. Receives the absolute path and the
|
|
29
|
+
* pre-read stat; the default (no hook) deletes the artifact after its
|
|
30
|
+
* bytes are inlined. Only local reads invoke it — resource/URL/blob
|
|
31
|
+
* resolutions never do (UT-46).
|
|
32
|
+
*/
|
|
33
|
+
cleanup?: (filePath: string, stat: fs.Stats) => void;
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
export interface FetchedArtifact {
|
|
@@ -81,7 +89,7 @@ export async function readLocation(location: string, deps: ArtifactDeps): Promis
|
|
|
81
89
|
|
|
82
90
|
const fileUri = location.startsWith('file:') ? location : toFileUri(location);
|
|
83
91
|
if (location.startsWith('file:')) {
|
|
84
|
-
const local = readLocalFileUri(location);
|
|
92
|
+
const local = readLocalFileUri(location, deps);
|
|
85
93
|
if (local) return local;
|
|
86
94
|
return deps.readResource ? deps.readResource(location) : null;
|
|
87
95
|
}
|
|
@@ -91,7 +99,7 @@ export async function readLocation(location: string, deps: ArtifactDeps): Promis
|
|
|
91
99
|
return deps.readResource ? deps.readResource(location) : null;
|
|
92
100
|
}
|
|
93
101
|
|
|
94
|
-
const local = readLocalFile(path.resolve(location));
|
|
102
|
+
const local = readLocalFile(path.resolve(location), deps);
|
|
95
103
|
if (local) return local;
|
|
96
104
|
return deps.readResource && fileUri ? deps.readResource(fileUri) : null;
|
|
97
105
|
}
|
|
@@ -109,26 +117,65 @@ function fromBase64(value: string, name: string | null, mime?: string): FetchedA
|
|
|
109
117
|
return { buffer, file_name: name ?? nameFromMime(mime), mime_type: mime };
|
|
110
118
|
}
|
|
111
119
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Read a file on the backend host: regular file only, size-capped, no symlinks.
|
|
122
|
+
*
|
|
123
|
+
* SA4E-380 F-01 (binding): the path's real location (symlinks/junctions/`..`
|
|
124
|
+
* resolved) must fall inside the dedicated ARTIFACT_OUTPUT_ROOTS BEFORE any
|
|
125
|
+
* filesystem access — an out-of-root path is never read and never deleted.
|
|
126
|
+
*
|
|
127
|
+
* SA4E-380 OI-14: once the bytes are inlined the local artifact is garbage —
|
|
128
|
+
* the cleanup hook (default: delete with F-06 stat-identity re-verification)
|
|
129
|
+
* runs after the successful read. A failed read never deletes anything.
|
|
130
|
+
*/
|
|
131
|
+
function readLocalFile(filePath: string, deps: ArtifactDeps): Buffer | null {
|
|
132
|
+
if (!isWithinArtifactRoots(filePath)) return null;
|
|
114
133
|
try {
|
|
115
134
|
const stat = fs.lstatSync(filePath);
|
|
116
135
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_ARTIFACT_BYTES) return null;
|
|
117
136
|
const buffer = fs.readFileSync(filePath);
|
|
118
|
-
|
|
137
|
+
if (buffer.length > MAX_ARTIFACT_BYTES) return null;
|
|
138
|
+
try {
|
|
139
|
+
(deps.cleanup ?? deleteLocalArtifact)(filePath, stat);
|
|
140
|
+
} catch {
|
|
141
|
+
// Cleanup failure never fails the tool call (UT-45) — bytes are safe.
|
|
142
|
+
console.warn(`[artifact-source] cleanup hook failed for '${path.basename(filePath)}'`);
|
|
143
|
+
}
|
|
144
|
+
return buffer;
|
|
119
145
|
} catch {
|
|
120
146
|
return null;
|
|
121
147
|
}
|
|
122
148
|
}
|
|
123
149
|
|
|
124
|
-
function readLocalFileUri(uri: string): Buffer | null {
|
|
150
|
+
function readLocalFileUri(uri: string, deps: ArtifactDeps): Buffer | null {
|
|
125
151
|
try {
|
|
126
|
-
return readLocalFile(fileURLToPath(uri));
|
|
152
|
+
return readLocalFile(fileURLToPath(uri), deps);
|
|
127
153
|
} catch {
|
|
128
154
|
return null;
|
|
129
155
|
}
|
|
130
156
|
}
|
|
131
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Default OI-14 cleanup: delete the artifact whose bytes were just inlined.
|
|
160
|
+
* F-06 TOCTOU guard — re-stat and verify identity (ino/size/mtimeMs) before
|
|
161
|
+
* unlink so a path swapped between read and delete is never removed.
|
|
162
|
+
*/
|
|
163
|
+
function deleteLocalArtifact(filePath: string, before: fs.Stats): void {
|
|
164
|
+
let now: fs.Stats;
|
|
165
|
+
try {
|
|
166
|
+
now = fs.lstatSync(filePath);
|
|
167
|
+
} catch {
|
|
168
|
+
return; // already gone — nothing to delete
|
|
169
|
+
}
|
|
170
|
+
if (now.ino !== before.ino || now.size !== before.size || now.mtimeMs !== before.mtimeMs) return;
|
|
171
|
+
try {
|
|
172
|
+
fs.unlinkSync(filePath);
|
|
173
|
+
} catch {
|
|
174
|
+
// I-3: basename only — never leak host paths into logs.
|
|
175
|
+
console.warn(`[artifact-source] could not delete local artifact '${path.basename(filePath)}'`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
132
179
|
/** Cross-platform file:// URI — a POSIX path stays POSIX even on a Windows host. */
|
|
133
180
|
function toFileUri(location: string): string | null {
|
|
134
181
|
try {
|
|
@@ -125,11 +125,14 @@ describe.skipIf(!dockerAvailable)('Sandbox module — Docker integration (TC-01,
|
|
|
125
125
|
const createParsed = JSON.parse(create.content[0].text);
|
|
126
126
|
const sessionId = createParsed.sessionId;
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
// No `|| true` here: that would force the shell to exit 0 and mask the very
|
|
129
|
+
// failure we assert. In network:none mode curl cannot resolve/connect, so it
|
|
130
|
+
// exits non-zero (6/7/28) and returns no body — both prove isolation holds.
|
|
131
|
+
const res = await handlers.get('sandbox_exec')!({ command: 'curl -s -m 5 https://google.com', sessionId });
|
|
129
132
|
const parsed = JSON.parse(res.content[0].text);
|
|
130
|
-
//
|
|
131
|
-
// Since we are asserting isolation, an exit code of 0 means it reached the internet.
|
|
133
|
+
// An exit code of 0 with a response body would mean the container reached the internet.
|
|
132
134
|
expect(parsed.exitCode).not.toBe(0);
|
|
135
|
+
expect(parsed.stdout.trim()).toBe('');
|
|
133
136
|
await handlers.get('sandbox_session')!({ action: 'destroy', sessionId });
|
|
134
137
|
}, 30000);
|
|
135
138
|
|