dsh-analyze 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-analyze contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,109 @@
1
- # Temporary Holding Version
1
+ # dsh-analyze
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [English](README.md) | [中文](README.zh.md)
4
+
5
+ **Local dataset analysis tools for [DSH](https://github.com/deepseek-ai/deepseek-harness).**
6
+ Ingest a CSV/JSONL once, keep large tables on disk, and let the model work
7
+ through small JSON tool calls instead of dumping entire files into context.
8
+
9
+ ```text
10
+ Without dsh-analyze With dsh-analyze
11
+ ───────────────────── ─────────────────────────────
12
+ paste whole CSV → tokens dataset_ingest → { datasetId }
13
+ context blows up dataset_head → 5 sample rows
14
+ no audit trail stats_describe → column stats
15
+ transform_agg → grouped result
16
+ (full table stays on disk)
17
+ ```
18
+
19
+ ## What it is (and is not)
20
+
21
+ | Is | Is not |
22
+ |---|---|
23
+ | Local **file** dataset handles (CSV / JSONL) | A SQL database connector |
24
+ | Offline Rust CLI + DSH tool plugin | A Python/R scientific stack |
25
+ | Session-workspace sandbox with hard limits | A multi-tenant cloud service |
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ dsh plugin --profile web add dsh-analyze
31
+ ```
32
+
33
+ Place the `dsh-analyze` binary on `PATH`, or set:
34
+
35
+ ```bash
36
+ export DSH_ANALYZE_CLI_PATH=/path/to/dsh-analyze
37
+ ```
38
+
39
+ ### Prebuilt binaries
40
+
41
+ Download from GitHub Releases and verify against `SHA256SUMS`:
42
+
43
+ | Target | Artifact |
44
+ |---|---|
45
+ | `x86_64-pc-windows-msvc` | `dsh-analyze-x86_64-pc-windows-msvc.zip` |
46
+ | `aarch64-apple-darwin` | `dsh-analyze-aarch64-apple-darwin.tar.gz` |
47
+ | `x86_64-apple-darwin` | `dsh-analyze-x86_64-apple-darwin.tar.gz` |
48
+ | `x86_64-unknown-linux-gnu` | `dsh-analyze-x86_64-unknown-linux-gnu.tar.gz` |
49
+
50
+ CLI lookup order: `DSH_ANALYZE_CLI_PATH` → legacy `INV_ANALYSIS_CLI_PATH` →
51
+ `dsh-analyze` on `PATH` → plugin logs a warning and registers nothing.
52
+
53
+ ### Build from source
54
+
55
+ ```bash
56
+ cargo build --release -p dsh-analyze
57
+ ```
58
+
59
+ The crate is pure-std and builds offline.
60
+
61
+ ## Tools
62
+
63
+ | Tool | Purpose |
64
+ |---|---|
65
+ | `dataset_ingest` | Load a workspace CSV/JSONL → `datasetId` + schema summary |
66
+ | `dataset_head` | Up to 20 sample rows |
67
+ | `dataset_schema` | Column names, dtypes, row count |
68
+ | `dataset_list` | All datasets in this workspace store |
69
+ | `dataset_export` | Write full table to CSV under outputs/ |
70
+ | `dataset_rm` | Permanently remove a dataset |
71
+ | `stats_describe` | Null counts, min/max/mean/std/quartiles |
72
+ | `transform_agg` | Filter / project / group-by / aggregate |
73
+
74
+ ## Limits & data root
75
+
76
+ ```text
77
+ <workspace>/.dsh-analyze/{datasets,outputs,tmp}/
78
+ ```
79
+
80
+ | Env | Default | Meaning |
81
+ |---|---|---|
82
+ | `DSH_ANALYZE_CLI_PATH` | — | CLI executable path |
83
+ | `DSH_ANALYZE_WORKSPACE` | `process.cwd()` | Session workspace root |
84
+ | `DSH_ANALYZE_MAX_ROWS` | 5,000,000 | Ingest row cap |
85
+ | `DSH_ANALYZE_MAX_INGEST_BYTES` | 256 MiB | Ingest byte cap |
86
+ | `DSH_ANALYZE_DEFAULT_ROW_LIMIT` | 1000 | Agg default row limit |
87
+
88
+ Legacy `INV_ANALYSIS_*` names are still accepted (new name wins).
89
+
90
+ Results returned to the model are intentionally small (samples ≤ 20 rows).
91
+ Full tables go through `outLabel` / `dataset_export` and stay on disk.
92
+
93
+ ## Comparison
94
+
95
+ | | dsh-analyze | SQL connectors | Notebook stacks (Python/R) |
96
+ |---|---|---|---|
97
+ | Input | Local CSV/JSONL | Live database | Anything |
98
+ | Network | Never | Required | Often |
99
+ | Model token cost | Small JSON per step | Query results | Kernel output |
100
+ | Runtime | Single static binary | Drivers + server | Full interpreter |
101
+
102
+ ## Security
103
+
104
+ Path confinement, credential-path deny lists, env whitelist, and hard
105
+ limits — see [SECURITY.md](SECURITY.md) and [docs/security.md](docs/security.md).
106
+
107
+ ## License
108
+
109
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-analyze
3
+ name: 'dsh-analyze'
package/dist/cli.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { AnalysisLimits } from './limits.js';
2
+ export interface AnalyzeCliResult {
3
+ ok: true;
4
+ data: Record<string, unknown>;
5
+ }
6
+ export declare class AnalyzeCliError extends Error {
7
+ readonly code: string;
8
+ readonly stderr: string;
9
+ constructor(code: string, message: string, stderr?: string);
10
+ }
11
+ export interface RunCliOptions {
12
+ workspaceRoot: string;
13
+ cliPath: string;
14
+ /** Extra argv inserted before analyze args (e.g. mock script path when cliPath is node). */
15
+ cliPrefixArgs?: readonly string[];
16
+ args: string[];
17
+ limits: AnalysisLimits;
18
+ signal?: AbortSignal;
19
+ }
20
+ export declare function runAnalyzeCli(options: RunCliOptions): Promise<AnalyzeCliResult>;
21
+ /** Probe CLI version/capabilities (T0 §4.2). Does not require a real workspace file. */
22
+ export declare function probeAnalyzeCli(options: {
23
+ workspaceRoot: string;
24
+ cliPath: string;
25
+ cliPrefixArgs?: readonly string[];
26
+ limits: AnalysisLimits;
27
+ }): Promise<{
28
+ version: string;
29
+ capabilities: string[];
30
+ }>;
31
+ /** Sync probe for register-time gates (apply path is sync). */
32
+ export type AnalyzeCliProbeResult = {
33
+ ok: true;
34
+ version: string;
35
+ capabilities: string[];
36
+ } | {
37
+ ok: false;
38
+ error: string;
39
+ };
40
+ /** Clear probe cache (tests / after DSH_ANALYZE_CLI_PATH change). */
41
+ export declare function clearAnalyzeCliProbeCache(): void;
42
+ /**
43
+ * Resolve analysis CLI executable: `DSH_ANALYZE_CLI_PATH` → legacy
44
+ * `INV_ANALYSIS_CLI_PATH` (compat window) → bare `dsh-analyze` on PATH.
45
+ */
46
+ export declare function resolveAnalysisCliPath(env?: Record<string, string | undefined>): string;
47
+ export declare function probeAnalyzeCliSync(options: {
48
+ workspaceRoot: string;
49
+ cliPath: string;
50
+ cliPrefixArgs?: readonly string[];
51
+ timeoutMs?: number;
52
+ /** Bypass sticky cache (tests / forced re-probe). */
53
+ force?: boolean;
54
+ }): AnalyzeCliProbeResult;
55
+ export declare function rethrowAnalysis(e: unknown): never;
package/dist/cli.js ADDED
@@ -0,0 +1,221 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { AnalysisPathError } from './paths.js';
3
+ export class AnalyzeCliError extends Error {
4
+ code;
5
+ stderr;
6
+ constructor(code, message, stderr = '') {
7
+ super(message);
8
+ this.name = 'AnalyzeCliError';
9
+ this.code = code;
10
+ this.stderr = stderr;
11
+ }
12
+ }
13
+ /** Kill process tree on Windows; plain kill elsewhere. */
14
+ function killChildTree(child) {
15
+ if (process.platform === 'win32' && typeof child.pid === 'number' && child.pid > 0) {
16
+ try {
17
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
18
+ stdio: 'ignore',
19
+ windowsHide: true,
20
+ });
21
+ const maybeUnref = killer;
22
+ if (typeof maybeUnref.unref === 'function')
23
+ maybeUnref.unref();
24
+ }
25
+ catch { /* fall through to kill */ }
26
+ }
27
+ try {
28
+ child.kill();
29
+ }
30
+ catch { /* already dead */ }
31
+ }
32
+ function mapCliFailure(exitCode, stderr) {
33
+ const line = stderr.split(/\r?\n/).map(s => s.trim()).filter(Boolean).pop() ?? 'analysis failed';
34
+ if (line.startsWith('analysis/')) {
35
+ const code = line.split(':')[0] ?? 'analysis/cli-failed';
36
+ return new AnalyzeCliError(code, line, stderr);
37
+ }
38
+ if (exitCode === 5) {
39
+ return new AnalyzeCliError('analysis/unknown-dataset', line, stderr);
40
+ }
41
+ if (exitCode === 3) {
42
+ return new AnalyzeCliError('analysis/limit', line, stderr);
43
+ }
44
+ if (exitCode === 2) {
45
+ return new AnalyzeCliError('analysis/invalid-args', line, stderr);
46
+ }
47
+ return new AnalyzeCliError('analysis/cli-failed', line, stderr);
48
+ }
49
+ export async function runAnalyzeCli(options) {
50
+ const { workspaceRoot, cliPath, cliPrefixArgs, args, limits, signal } = options;
51
+ const argv = [...(cliPrefixArgs ?? []), '--workspace', workspaceRoot, ...args];
52
+ return await new Promise((resolvePromise, rejectPromise) => {
53
+ const child = spawn(cliPath, argv, {
54
+ cwd: workspaceRoot,
55
+ stdio: ['ignore', 'pipe', 'pipe'],
56
+ signal,
57
+ windowsHide: true,
58
+ env: {
59
+ PATH: process.env.PATH,
60
+ DSH_ANALYZE_MAX_ROWS: String(limits.maxRows),
61
+ DSH_ANALYZE_MAX_INGEST_BYTES: String(limits.maxIngestBytes),
62
+ DSH_ANALYZE_DEFAULT_ROW_LIMIT: String(limits.defaultRowLimit),
63
+ ...(process.env.NODE_ENV ? { NODE_ENV: process.env.NODE_ENV } : {}),
64
+ },
65
+ });
66
+ let stdout = '';
67
+ let stderr = '';
68
+ let settled = false;
69
+ const timer = setTimeout(() => {
70
+ if (settled)
71
+ return;
72
+ settled = true;
73
+ killChildTree(child);
74
+ rejectPromise(new AnalyzeCliError('analysis/limit', `analysis timed out after ${limits.timeoutMs}ms`));
75
+ }, limits.timeoutMs);
76
+ const timerLike = timer;
77
+ if (typeof timerLike.unref === 'function') {
78
+ timerLike.unref();
79
+ }
80
+ child.stdout.setEncoding('utf8');
81
+ child.stderr.setEncoding('utf8');
82
+ child.stdout.on('data', (chunk) => {
83
+ stdout += chunk;
84
+ if (stdout.length > limits.maxOutputBytes * 4) {
85
+ killChildTree(child);
86
+ }
87
+ });
88
+ child.stderr.on('data', (chunk) => {
89
+ stderr += chunk;
90
+ if (stderr.length > 64_000)
91
+ stderr = stderr.slice(-64_000);
92
+ });
93
+ child.on('error', (err) => {
94
+ if (settled)
95
+ return;
96
+ settled = true;
97
+ clearTimeout(timer);
98
+ rejectPromise(new AnalyzeCliError('analysis/cli-missing', err.message));
99
+ });
100
+ child.on('close', (code) => {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ clearTimeout(timer);
105
+ if (code !== 0) {
106
+ rejectPromise(mapCliFailure(code, stderr));
107
+ return;
108
+ }
109
+ try {
110
+ const trimmed = stdout.trim();
111
+ const data = JSON.parse(trimmed);
112
+ if (data.ok !== true) {
113
+ rejectPromise(new AnalyzeCliError('analysis/cli-failed', trimmed.slice(0, 500), stderr));
114
+ return;
115
+ }
116
+ resolvePromise({ ok: true, data });
117
+ }
118
+ catch {
119
+ rejectPromise(new AnalyzeCliError('analysis/cli-failed', 'invalid CLI JSON', stderr + '\n' + stdout.slice(0, 200)));
120
+ }
121
+ });
122
+ });
123
+ }
124
+ /** Probe CLI version/capabilities (T0 §4.2). Does not require a real workspace file. */
125
+ export async function probeAnalyzeCli(options) {
126
+ const result = await runAnalyzeCli({
127
+ workspaceRoot: options.workspaceRoot,
128
+ cliPath: options.cliPath,
129
+ cliPrefixArgs: options.cliPrefixArgs,
130
+ args: ['version'],
131
+ limits: options.limits,
132
+ });
133
+ const data = result.data;
134
+ const version = typeof data.version === 'string' ? data.version : '';
135
+ const capabilities = Array.isArray(data.capabilities)
136
+ ? data.capabilities.filter((c) => typeof c === 'string')
137
+ : [];
138
+ if (!version || capabilities.length === 0) {
139
+ throw new AnalyzeCliError('analysis/cli-missing', 'CLI version probe returned incomplete capabilities');
140
+ }
141
+ return { version, capabilities };
142
+ }
143
+ /** Process-local probe cache: success is sticky; failure retries after TTL. */
144
+ const probeCache = new Map();
145
+ const PROBE_FAIL_TTL_MS = 30_000;
146
+ /** Clear probe cache (tests / after DSH_ANALYZE_CLI_PATH change). */
147
+ export function clearAnalyzeCliProbeCache() {
148
+ probeCache.clear();
149
+ }
150
+ /**
151
+ * Resolve analysis CLI executable: `DSH_ANALYZE_CLI_PATH` → legacy
152
+ * `INV_ANALYSIS_CLI_PATH` (compat window) → bare `dsh-analyze` on PATH.
153
+ */
154
+ export function resolveAnalysisCliPath(env = process.env) {
155
+ const preferred = env.DSH_ANALYZE_CLI_PATH;
156
+ if (preferred && preferred.trim())
157
+ return preferred.trim();
158
+ const legacy = env.INV_ANALYSIS_CLI_PATH;
159
+ if (legacy && legacy.trim())
160
+ return legacy.trim();
161
+ return 'dsh-analyze';
162
+ }
163
+ function probeCacheKey(workspaceRoot, cliPath, prefix) {
164
+ return JSON.stringify([cliPath, workspaceRoot, ...(prefix ?? [])]);
165
+ }
166
+ export function probeAnalyzeCliSync(options) {
167
+ const key = probeCacheKey(options.workspaceRoot, options.cliPath, options.cliPrefixArgs);
168
+ if (!options.force) {
169
+ const hit = probeCache.get(key);
170
+ if (hit) {
171
+ if (hit.result.ok)
172
+ return hit.result;
173
+ if (Date.now() - hit.at < PROBE_FAIL_TTL_MS)
174
+ return hit.result;
175
+ }
176
+ }
177
+ const result = probeAnalyzeCliUncached(options);
178
+ probeCache.set(key, { result, at: Date.now() });
179
+ return result;
180
+ }
181
+ function probeAnalyzeCliUncached(options) {
182
+ const argv = [...(options.cliPrefixArgs ?? []), '--workspace', options.workspaceRoot, 'version'];
183
+ try {
184
+ const r = spawnSync(options.cliPath, argv, {
185
+ encoding: 'utf8',
186
+ timeout: options.timeoutMs ?? 10_000,
187
+ windowsHide: true,
188
+ env: {
189
+ PATH: process.env.PATH,
190
+ ...(process.env.NODE_ENV ? { NODE_ENV: process.env.NODE_ENV } : {}),
191
+ },
192
+ });
193
+ if (r.error) {
194
+ return { ok: false, error: r.error.message };
195
+ }
196
+ const stdout = r.stdout ?? '';
197
+ const stderr = r.stderr ?? '';
198
+ if (r.status !== 0) {
199
+ return { ok: false, error: stderr.slice(0, 200) || `exit ${String(r.status)}` };
200
+ }
201
+ const data = JSON.parse(stdout.trim());
202
+ if (data.ok !== true || typeof data.version !== 'string' || !Array.isArray(data.capabilities)) {
203
+ return { ok: false, error: 'invalid CLI version JSON' };
204
+ }
205
+ const capabilities = data.capabilities.filter((c) => typeof c === 'string');
206
+ if (!data.version || capabilities.length === 0) {
207
+ return { ok: false, error: 'CLI version probe returned incomplete capabilities' };
208
+ }
209
+ return { ok: true, version: data.version, capabilities };
210
+ }
211
+ catch (e) {
212
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
213
+ }
214
+ }
215
+ export function rethrowAnalysis(e) {
216
+ if (e instanceof AnalysisPathError || e instanceof AnalyzeCliError) {
217
+ throw e;
218
+ }
219
+ const msg = e instanceof Error ? e.message : String(e);
220
+ throw new AnalyzeCliError('analysis/cli-failed', msg);
221
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Host half of the dsh-analyze DSH plugin.
3
+ *
4
+ * Registers session-dataset analysis tools (`dataset_*`, `stats_describe`,
5
+ * `transform_agg`) on the global tool registry when the `dsh-analyze` CLI
6
+ * is discoverable. Fail-soft: a missing CLI logs a warning and registers
7
+ * nothing — the profile stays usable without analysis tools.
8
+ *
9
+ * Workspace: `DSH_ANALYZE_WORKSPACE` if set, else process cwd. Datasets live
10
+ * under `<workspace>/.dsh-analyze/{datasets,outputs,tmp}` and never leave it.
11
+ *
12
+ * CLI path: `DSH_ANALYZE_CLI_PATH` → legacy `INV_ANALYSIS_CLI_PATH` → PATH
13
+ * `dsh-analyze`.
14
+ * @module dsh-analyze
15
+ */
16
+ import type { Context } from '@deepseek-ai/cordis';
17
+ export declare function apply(ctx: Context): void;
package/dist/index.js ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Host half of the dsh-analyze DSH plugin.
3
+ *
4
+ * Registers session-dataset analysis tools (`dataset_*`, `stats_describe`,
5
+ * `transform_agg`) on the global tool registry when the `dsh-analyze` CLI
6
+ * is discoverable. Fail-soft: a missing CLI logs a warning and registers
7
+ * nothing — the profile stays usable without analysis tools.
8
+ *
9
+ * Workspace: `DSH_ANALYZE_WORKSPACE` if set, else process cwd. Datasets live
10
+ * under `<workspace>/.dsh-analyze/{datasets,outputs,tmp}` and never leave it.
11
+ *
12
+ * CLI path: `DSH_ANALYZE_CLI_PATH` → legacy `INV_ANALYSIS_CLI_PATH` → PATH
13
+ * `dsh-analyze`.
14
+ * @module dsh-analyze
15
+ */
16
+ import { probeAnalyzeCliSync, resolveAnalysisCliPath } from './cli.js';
17
+ import { ANALYSIS_TOOL_NAMES } from './policy.js';
18
+ import { createAnalysisToolDefinitions } from './tools.js';
19
+ function getTools(ctx) {
20
+ try {
21
+ return ctx.root.get('tools');
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ function workspaceRoot() {
28
+ const env = process.env.DSH_ANALYZE_WORKSPACE;
29
+ if (env && env.trim())
30
+ return env.trim();
31
+ return process.cwd();
32
+ }
33
+ export function apply(ctx) {
34
+ const tools = getTools(ctx);
35
+ if (!tools || typeof tools.register !== 'function') {
36
+ console.warn('[dsh-analyze] tools runtime unavailable; analysis tools not registered');
37
+ return;
38
+ }
39
+ const cliPath = resolveAnalysisCliPath();
40
+ const ws = workspaceRoot();
41
+ const probe = probeAnalyzeCliSync({ workspaceRoot: ws, cliPath, timeoutMs: 10_000 });
42
+ if (!probe.ok) {
43
+ console.warn('[dsh-analyze] CLI not found or probe failed; tools not registered:', probe.error);
44
+ return;
45
+ }
46
+ const defs = createAnalysisToolDefinitions({ cliPath, workspaceRoot: ws });
47
+ const offs = ANALYSIS_TOOL_NAMES.map((name) => tools.register(defs[name]));
48
+ console.info(`[dsh-analyze] registered ${offs.length} tools (cli ${probe.version}) at ${ws}`);
49
+ ctx.effect(() => () => {
50
+ for (const off of offs) {
51
+ try {
52
+ off();
53
+ }
54
+ catch { /* already gone */ }
55
+ }
56
+ }, 'dsh-analyze-tools');
57
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared limits for analysis tools / CLI bridge.
3
+ * @module @investigator/backend/analysis/limits
4
+ */
5
+ export interface AnalysisLimits {
6
+ timeoutMs: number;
7
+ maxOutputBytes: number;
8
+ maxSampleRows: number;
9
+ maxHeadRows: number;
10
+ maxIngestBytes: number;
11
+ maxRows: number;
12
+ defaultRowLimit: number;
13
+ maxSpecBytes: number;
14
+ maxAggLimit: number;
15
+ }
16
+ export declare const DEFAULT_ANALYSIS_LIMITS: AnalysisLimits;
17
+ export declare function clampHeadN(n: unknown, limits: AnalysisLimits): number;
package/dist/limits.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Shared limits for analysis tools / CLI bridge.
3
+ * @module @investigator/backend/analysis/limits
4
+ */
5
+ export const DEFAULT_ANALYSIS_LIMITS = {
6
+ timeoutMs: 60_000,
7
+ maxOutputBytes: 262_144,
8
+ maxSampleRows: 8,
9
+ maxHeadRows: 20,
10
+ maxIngestBytes: 268_435_456,
11
+ maxRows: 5_000_000,
12
+ defaultRowLimit: 1000,
13
+ maxSpecBytes: 32_768,
14
+ maxAggLimit: 5000,
15
+ };
16
+ export function clampHeadN(n, limits) {
17
+ const raw = typeof n === 'number' && Number.isFinite(n) ? Math.floor(n) : 5;
18
+ return Math.min(Math.max(raw, 1), limits.maxHeadRows);
19
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Workspace path confinement for analysis datasets.
3
+ * @module @investigator/backend/analysis/paths
4
+ */
5
+ export declare class AnalysisPathError extends Error {
6
+ readonly code: string;
7
+ constructor(code: string, message: string);
8
+ }
9
+ export declare function newDatasetId(): string;
10
+ export declare function analysisRoot(workspaceRoot: string): string;
11
+ export declare function datasetsDir(workspaceRoot: string): string;
12
+ export declare function outputsDir(workspaceRoot: string): string;
13
+ export declare function tmpDir(workspaceRoot: string): string;
14
+ /**
15
+ * Per-session analysis workspace under a profile dir.
16
+ * Never the profile root itself — sessions cannot read each other's data
17
+ * or profile credentials. Creates the directory once.
18
+ */
19
+ export declare function ensureSessionWorkspaceRoot(profileDir: string, sessionId: string): string;
20
+ /** Resolve user path and require it stays under workspaceRoot. */
21
+ export declare function resolveInWorkspace(workspaceRoot: string, userPath: string): string;
22
+ export declare function validateDatasetId(id: string): void;
package/dist/paths.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Workspace path confinement for analysis datasets.
3
+ * @module @investigator/backend/analysis/paths
4
+ */
5
+ import { randomBytes } from 'node:crypto';
6
+ import { existsSync, mkdirSync } from 'node:fs';
7
+ import { isAbsolute, join, normalize, resolve, sep } from 'node:path';
8
+ export class AnalysisPathError extends Error {
9
+ code;
10
+ constructor(code, message) {
11
+ super(message);
12
+ this.name = 'AnalysisPathError';
13
+ this.code = code;
14
+ }
15
+ }
16
+ export function newDatasetId() {
17
+ return `ds_${randomBytes(6).toString('hex')}`;
18
+ }
19
+ export function analysisRoot(workspaceRoot) {
20
+ return join(workspaceRoot, '.dsh-analyze');
21
+ }
22
+ export function datasetsDir(workspaceRoot) {
23
+ return join(analysisRoot(workspaceRoot), 'datasets');
24
+ }
25
+ export function outputsDir(workspaceRoot) {
26
+ return join(analysisRoot(workspaceRoot), 'outputs');
27
+ }
28
+ export function tmpDir(workspaceRoot) {
29
+ return join(analysisRoot(workspaceRoot), 'tmp');
30
+ }
31
+ /**
32
+ * Per-session analysis workspace under a profile dir.
33
+ * Never the profile root itself — sessions cannot read each other's data
34
+ * or profile credentials. Creates the directory once.
35
+ */
36
+ export function ensureSessionWorkspaceRoot(profileDir, sessionId) {
37
+ const sid = sessionId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 128) || 'anon';
38
+ const root = join(profileDir, 'inv-analysis', 'sessions', sid);
39
+ if (!existsSync(root)) {
40
+ mkdirSync(root, { recursive: true });
41
+ }
42
+ return root;
43
+ }
44
+ /** Resolve user path and require it stays under workspaceRoot. */
45
+ export function resolveInWorkspace(workspaceRoot, userPath) {
46
+ if (!userPath || typeof userPath !== 'string') {
47
+ throw new AnalysisPathError('analysis/invalid-path', 'path is required');
48
+ }
49
+ const ws = resolve(workspaceRoot);
50
+ const abs = isAbsolute(userPath) ? normalize(userPath) : resolve(ws, userPath);
51
+ if (abs !== ws && !abs.startsWith(ws + sep)) {
52
+ throw new AnalysisPathError('analysis/invalid-path', 'path escapes workspace');
53
+ }
54
+ return abs;
55
+ }
56
+ export function validateDatasetId(id) {
57
+ if (!/^ds_[a-f0-9]{8,32}$/.test(id)) {
58
+ throw new AnalysisPathError('analysis/invalid-args', 'bad datasetId');
59
+ }
60
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Expert allowlist helpers for analysis tools.
3
+ * @module @investigator/backend/analysis/policy
4
+ */
5
+ export declare const ANALYSIS_TOOL_NAMES: readonly ["dataset_ingest", "dataset_head", "dataset_schema", "dataset_list", "dataset_export", "dataset_rm", "stats_describe", "transform_agg"];
6
+ export type AnalysisToolName = (typeof ANALYSIS_TOOL_NAMES)[number];
7
+ export declare function isAnalysisToolName(name: string): name is AnalysisToolName;
8
+ /**
9
+ * Analysis tools to register on an agent scope for one expert.
10
+ * - deny-all → none
11
+ * - explicit `analysisTools` wins (any mode except deny-all)
12
+ * - allowlist → intersection with toolAllowlist
13
+ * - unconfigured without analysisTools → none (safe default)
14
+ */
15
+ export declare function analysisToolsForExpert(input: {
16
+ mode: 'unconfigured' | 'deny-all' | 'allowlist';
17
+ toolAllowlist: readonly string[];
18
+ analysisTools?: readonly string[];
19
+ }): AnalysisToolName[];
package/dist/policy.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Expert allowlist helpers for analysis tools.
3
+ * @module @investigator/backend/analysis/policy
4
+ */
5
+ export const ANALYSIS_TOOL_NAMES = [
6
+ 'dataset_ingest',
7
+ 'dataset_head',
8
+ 'dataset_schema',
9
+ 'dataset_list',
10
+ 'dataset_export',
11
+ 'dataset_rm',
12
+ 'stats_describe',
13
+ 'transform_agg',
14
+ ];
15
+ export function isAnalysisToolName(name) {
16
+ return ANALYSIS_TOOL_NAMES.includes(name);
17
+ }
18
+ /**
19
+ * Analysis tools to register on an agent scope for one expert.
20
+ * - deny-all → none
21
+ * - explicit `analysisTools` wins (any mode except deny-all)
22
+ * - allowlist → intersection with toolAllowlist
23
+ * - unconfigured without analysisTools → none (safe default)
24
+ */
25
+ export function analysisToolsForExpert(input) {
26
+ if (input.mode === 'deny-all')
27
+ return [];
28
+ if (input.analysisTools && input.analysisTools.length > 0) {
29
+ const set = new Set(input.analysisTools);
30
+ return ANALYSIS_TOOL_NAMES.filter(n => set.has(n));
31
+ }
32
+ if (input.mode !== 'allowlist')
33
+ return [];
34
+ const allow = new Set(input.toolAllowlist);
35
+ return ANALYSIS_TOOL_NAMES.filter(n => allow.has(n));
36
+ }