sandoichi 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/index.mjs +2 -0
- package/package.json +1 -1
- package/src/core.mjs +18 -5
- package/src/hook-cli.mjs +43 -18
- package/src/mcp-server.mjs +2 -1
- package/src/postinstall.mjs +2 -1
- package/src/proxy.mjs +94 -47
- package/src/redaction-config.mjs +104 -0
- package/src/redaction-profile.mjs +163 -0
- package/src/secret-redaction.mjs +12 -16
- package/src/semantic-compactor.mjs +14 -5
- package/src/session-start.mjs +15 -3
- package/src/telemetry-cli.mjs +21 -3
- package/src/telemetry-flush-entry.mjs +3 -3
- package/src/telemetry.mjs +223 -64
- package/src/version.mjs +29 -0
package/README.md
CHANGED
|
@@ -10,6 +10,20 @@ npm install sandoichi
|
|
|
10
10
|
import { optimizeToolOutput, createProviderProxy } from 'sandoichi';
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
+
Project-specific detectors can be declared in `.sando/redaction.json`:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"schema": "sando-redaction/v1",
|
|
18
|
+
"rules": [
|
|
19
|
+
{ "type": "assignment-key", "key": "DATABASE_URL" },
|
|
20
|
+
{ "type": "token-prefix", "prefix": "acme_", "minLength": 24, "maxLength": 128 }
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Built-ins stay enabled. Profiles are declarative and local to the current project; invalid profiles fail visibly.
|
|
26
|
+
|
|
13
27
|
The library requires Node.js `>=22.22.0 <23` and has no runtime dependencies. Installing it does not install or enable the plugin.
|
|
14
28
|
|
|
15
29
|
For plugin installation, see the [main project README](https://github.com/yuzushi-dev/Sando#readme).
|
package/index.mjs
CHANGED
|
@@ -5,6 +5,8 @@ export {
|
|
|
5
5
|
normalizePolicy,
|
|
6
6
|
optimizeToolOutput,
|
|
7
7
|
} from './src/core.mjs';
|
|
8
|
+
export { createRedactionProfile } from './src/redaction-profile.mjs';
|
|
9
|
+
export { loadProjectRedactionProfile } from './src/redaction-config.mjs';
|
|
8
10
|
export { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './src/context-transform.mjs';
|
|
9
11
|
export {
|
|
10
12
|
buildSemanticPrompt,
|
package/package.json
CHANGED
package/src/core.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { planToolRoute, ROUTING_POLICY_VERSION } from './routing.mjs';
|
|
4
|
-
import {
|
|
4
|
+
import { loadProjectRedactionProfile } from './redaction-config.mjs';
|
|
5
5
|
|
|
6
6
|
const DEFAULT_POLICY = Object.freeze({
|
|
7
7
|
mode: 'apply', maxInlineBytes: 4096, maxArtifactBytes: 65536, headBytes: undefined, tailBytes: undefined,
|
|
@@ -31,6 +31,14 @@ function textOutput(output) {
|
|
|
31
31
|
return value;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function resolveRedactionProfile(cwd, candidate) {
|
|
35
|
+
const profile = candidate ?? loadProjectRedactionProfile(cwd).profile;
|
|
36
|
+
if (!profile || typeof profile.redact !== 'function' || typeof profile.digest !== 'string') {
|
|
37
|
+
throw new TypeError('redactionProfile is invalid');
|
|
38
|
+
}
|
|
39
|
+
return profile;
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
function truncateUtf8(text, maxBytes) {
|
|
35
43
|
if (Buffer.byteLength(text) <= maxBytes) return text;
|
|
36
44
|
let bytes = 0;
|
|
@@ -140,7 +148,7 @@ export function normalizePolicy(policy = {}) {
|
|
|
140
148
|
|
|
141
149
|
export function optimizeToolOutput({
|
|
142
150
|
toolName, output, cwd, policy, selector, raw, lineCount, fileBytes, prose, summarizeProse,
|
|
143
|
-
summarizeEnabled, grepScope, outputBytes, toolInput,
|
|
151
|
+
summarizeEnabled, grepScope, outputBytes, toolInput, redactionProfile,
|
|
144
152
|
} = {}) {
|
|
145
153
|
if (typeof toolName !== 'string' || !toolName.trim() || toolName.length > 128) throw new Error('toolName is invalid');
|
|
146
154
|
if (typeof cwd !== 'string' || !cwd) throw new Error('cwd is invalid');
|
|
@@ -154,7 +162,8 @@ export function optimizeToolOutput({
|
|
|
154
162
|
lineCount: derivedLineCount, fileBytes: derivedFileBytes, prose, summarizeProse, summarizeEnabled, grepScope,
|
|
155
163
|
outputBytes: outputBytes ?? Buffer.byteLength(input),
|
|
156
164
|
});
|
|
157
|
-
const
|
|
165
|
+
const profile = normalizedPolicy.redact ? resolveRedactionProfile(cwd, redactionProfile) : null;
|
|
166
|
+
const redacted = profile ? profile.redact(input) : { text: input, count: 0 };
|
|
158
167
|
let modelText = name === 'bash' && normalizedPolicy.maxColumns >= 32
|
|
159
168
|
? collapseRepeatedLines(redacted.text)
|
|
160
169
|
: redacted.text;
|
|
@@ -214,7 +223,10 @@ export function optimizeToolOutput({
|
|
|
214
223
|
redactions: redacted.count,
|
|
215
224
|
artifactTruncated: artifact?.truncated ?? false,
|
|
216
225
|
};
|
|
217
|
-
const result = {
|
|
226
|
+
const result = {
|
|
227
|
+
inline, route: route.route, reason: route.source, policyVersion: ROUTING_POLICY_VERSION,
|
|
228
|
+
redactionProfileDigest: profile?.digest ?? null, stats,
|
|
229
|
+
};
|
|
218
230
|
if (artifact) result.artifact = artifact;
|
|
219
231
|
return result;
|
|
220
232
|
}
|
|
@@ -252,7 +264,8 @@ export function createReceipt({ host, event, optimization, replacement } = {}) {
|
|
|
252
264
|
sessionId: event.sessionId ?? null, inputDigest: sha256(textOutput(event.output)),
|
|
253
265
|
inlineDigest: sha256(textOutput(replacement === undefined ? optimization.inline : replacement)), artifactRef: optimization.artifact?.ref ?? null,
|
|
254
266
|
route: optimization.route ?? 'passthrough', reason: optimization.reason ?? 'spike-default',
|
|
255
|
-
policyVersion: optimization.policyVersion ?? ROUTING_POLICY_VERSION,
|
|
267
|
+
policyVersion: optimization.policyVersion ?? ROUTING_POLICY_VERSION,
|
|
268
|
+
redactionProfileDigest: optimization.redactionProfileDigest ?? null, stats: optimization.stats,
|
|
256
269
|
};
|
|
257
270
|
return { ...body, digest: sha256(stableJson(body)) };
|
|
258
271
|
}
|
package/src/hook-cli.mjs
CHANGED
|
@@ -3,42 +3,54 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
5
|
import { createReceipt, normalizeEvent, normalizePolicy, optimizeToolOutput } from './core.mjs';
|
|
6
|
+
import { loadProjectRedactionProfile } from './redaction-config.mjs';
|
|
6
7
|
import { defaultMetricsPath, recordMetrics } from './metrics.mjs';
|
|
7
8
|
import {
|
|
8
|
-
closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, readTelemetryConfig,
|
|
9
|
+
closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, isDoNotTrack, readTelemetryConfig, recordActiveDay, recordFailure,
|
|
9
10
|
} from './telemetry.mjs';
|
|
10
|
-
|
|
11
|
-
const PLUGIN_VERSION = '0.1';
|
|
11
|
+
import { PLUGIN_VERSION } from './version.mjs';
|
|
12
12
|
|
|
13
13
|
function todayUtc() { return new Date().toISOString().slice(0, 10); }
|
|
14
14
|
|
|
15
|
-
/** Only counts (never content, paths, or IDs)
|
|
16
|
-
* `enforce` covers both `apply` and `dry-run`: both walk the real rewrite path, only
|
|
17
|
-
* `observe` collects without deciding anything. */
|
|
15
|
+
/** Only counts (never content, paths, or IDs). */
|
|
18
16
|
function recordHookTelemetry({ host, env, policy, optimization }) {
|
|
19
|
-
const configPath = defaultTelemetryConfigPath(env);
|
|
20
|
-
let config;
|
|
21
|
-
try { config = readTelemetryConfig(configPath); } catch { return; }
|
|
22
|
-
if (!config.enabled) return;
|
|
23
17
|
try {
|
|
18
|
+
const configPath = defaultTelemetryConfigPath(env);
|
|
19
|
+
const config = readTelemetryConfig(configPath);
|
|
20
|
+
if (!config.enabled || isDoNotTrack(env)) return;
|
|
24
21
|
const statePaths = defaultTelemetryStatePaths(env);
|
|
22
|
+
recordActiveDay({ statePaths, day: todayUtc(), pluginVersion: PLUGIN_VERSION, host });
|
|
25
23
|
incrementCounter({
|
|
26
24
|
statePaths,
|
|
27
25
|
day: todayUtc(),
|
|
28
26
|
event: 'hook_summary',
|
|
29
27
|
host,
|
|
30
|
-
mode: policy.mode === '
|
|
28
|
+
mode: policy.mode === 'apply' ? 'enforce' : policy.mode === 'dry-run' ? 'dry_run' : 'observe',
|
|
31
29
|
deltas: {
|
|
32
30
|
toolCalls: 1,
|
|
33
31
|
redactions: optimization.stats.redactions,
|
|
34
32
|
cappedOutputs: optimization.artifact ? 1 : 0,
|
|
35
33
|
bytesSaved: Math.max(0, optimization.stats.inputBytes - optimization.stats.inlineBytes),
|
|
34
|
+
inputTokensSaved: Math.max(0, optimization.stats.estimatedInputTokens - optimization.stats.estimatedInlineTokens),
|
|
36
35
|
},
|
|
37
36
|
});
|
|
38
37
|
closeFinishedDays({ statePaths, configPath, day: todayUtc(), pluginVersion: PLUGIN_VERSION });
|
|
39
38
|
} catch { /* telemetry is best-effort and must never affect hook output */ }
|
|
40
39
|
}
|
|
41
40
|
|
|
41
|
+
function recordHookFailure({ host, env, failureStage }) {
|
|
42
|
+
try {
|
|
43
|
+
const configPath = defaultTelemetryConfigPath(env);
|
|
44
|
+
const config = readTelemetryConfig(configPath);
|
|
45
|
+
if (!config.enabled || isDoNotTrack(env)) return;
|
|
46
|
+
const statePaths = defaultTelemetryStatePaths(env);
|
|
47
|
+
const day = todayUtc();
|
|
48
|
+
recordActiveDay({ statePaths, day, pluginVersion: PLUGIN_VERSION, host });
|
|
49
|
+
recordFailure({ statePaths, day, event: 'hook_failure_summary', host, failureStage });
|
|
50
|
+
closeFinishedDays({ statePaths, configPath, day, pluginVersion: PLUGIN_VERSION });
|
|
51
|
+
} catch { /* telemetry is best-effort and must never affect hook output */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
42
54
|
function hookPolicy(env, host) {
|
|
43
55
|
const policy = env.SANDO_POLICY
|
|
44
56
|
? JSON.parse(env.SANDO_POLICY)
|
|
@@ -78,27 +90,34 @@ export function runHookCli({ host, env = process.env } = {}) {
|
|
|
78
90
|
try {
|
|
79
91
|
policy = hookPolicy(env, host);
|
|
80
92
|
} catch (error) {
|
|
93
|
+
recordHookFailure({ host, env, failureStage: 'policy' });
|
|
81
94
|
process.stderr.write(`sando invalid policy: ${error instanceof Error ? error.message : 'invalid input'}\n`);
|
|
82
95
|
process.exitCode = 2;
|
|
83
96
|
return;
|
|
84
97
|
}
|
|
98
|
+
let failureStage = 'input';
|
|
85
99
|
try {
|
|
86
100
|
const input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
|
|
87
101
|
const eventName = input.hook_event_name ?? input.hookEventName ?? input.event_name ?? input.eventName;
|
|
88
102
|
if (eventName === 'PostToolUse') {
|
|
89
103
|
const event = normalizeEvent(input);
|
|
90
|
-
|
|
104
|
+
failureStage = 'redaction';
|
|
105
|
+
const redactionProfile = policy.redact ? loadProjectRedactionProfile(event.cwd).profile : undefined;
|
|
106
|
+
failureStage = 'optimization';
|
|
107
|
+
const optimization = optimizeToolOutput({ toolName: event.toolName, toolInput: event.toolInput, output: event.output, cwd: event.cwd, policy, redactionProfile });
|
|
91
108
|
let shaped;
|
|
109
|
+
failureStage = 'artifact';
|
|
92
110
|
if (host === 'claude' && policy.mode === 'apply') {
|
|
93
111
|
shaped = shapeForClaude({
|
|
94
112
|
original: event.output,
|
|
95
113
|
optimization,
|
|
96
114
|
toolName: event.toolName,
|
|
97
115
|
toolInput: event.toolInput,
|
|
98
|
-
cwd: event.cwd,
|
|
116
|
+
cwd: event.cwd, redactionProfile,
|
|
99
117
|
policy,
|
|
100
118
|
});
|
|
101
119
|
}
|
|
120
|
+
failureStage = 'output';
|
|
102
121
|
const receipt = createReceipt({ host, event, optimization, replacement: shaped });
|
|
103
122
|
try {
|
|
104
123
|
recordMetrics({ storagePath: defaultMetricsPath(env), host, event, optimization, receipt });
|
|
@@ -117,7 +136,13 @@ export function runHookCli({ host, env = process.env } = {}) {
|
|
|
117
136
|
}
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
|
-
} catch {
|
|
139
|
+
} catch (error) {
|
|
140
|
+
recordHookFailure({ host, env, failureStage });
|
|
141
|
+
if (error?.code === 'SANDO_REDACTION_CONFIG') {
|
|
142
|
+
process.stderr.write(`sando invalid redaction config: ${error.message}\n`);
|
|
143
|
+
process.exitCode = 2;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
121
146
|
process.stdout.write('{}\n');
|
|
122
147
|
}
|
|
123
148
|
|
|
@@ -135,19 +160,19 @@ export function buildCodexFallback({ optimization, cwd }) {
|
|
|
135
160
|
};
|
|
136
161
|
}
|
|
137
162
|
|
|
138
|
-
function shapeForClaude({ original, optimization, toolName, toolInput, cwd, policy }) {
|
|
163
|
+
function shapeForClaude({ original, optimization, toolName, toolInput, cwd, policy, redactionProfile }) {
|
|
139
164
|
if (typeof original === 'string') return materialize(optimization, cwd);
|
|
140
165
|
if (!original || typeof original !== 'object' || Array.isArray(original)
|
|
141
166
|
|| !Object.hasOwn(original, 'stdout') || !Object.hasOwn(original, 'stderr')
|
|
142
167
|
|| typeof original.stdout !== 'string' || typeof original.stderr !== 'string'
|
|
143
168
|
|| (Object.hasOwn(original, 'interrupted') && typeof original.interrupted !== 'boolean')
|
|
144
169
|
|| (Object.hasOwn(original, 'isImage') && typeof original.isImage !== 'boolean')) return undefined;
|
|
145
|
-
const result = { ...original };
|
|
170
|
+
const result = policy.redact ? redactionProfile.redactStructured(original).value : { ...original };
|
|
146
171
|
if (typeof original.stdout === 'string') {
|
|
147
|
-
result.stdout = materialize(optimizeToolOutput({ toolName, toolInput, output: original.stdout, cwd, policy }), cwd);
|
|
172
|
+
result.stdout = materialize(optimizeToolOutput({ toolName, toolInput, output: original.stdout, cwd, policy, redactionProfile }), cwd);
|
|
148
173
|
}
|
|
149
174
|
if (typeof original.stderr === 'string') {
|
|
150
|
-
result.stderr = materialize(optimizeToolOutput({ toolName, toolInput, output: original.stderr, cwd, policy }), cwd);
|
|
175
|
+
result.stderr = materialize(optimizeToolOutput({ toolName, toolInput, output: original.stderr, cwd, policy, redactionProfile }), cwd);
|
|
151
176
|
}
|
|
152
177
|
return result;
|
|
153
178
|
}
|
package/src/mcp-server.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import readline from 'node:readline';
|
|
2
2
|
|
|
3
3
|
import { optimizeToolOutput } from './core.mjs';
|
|
4
|
+
import { PLUGIN_VERSION } from './version.mjs';
|
|
4
5
|
|
|
5
6
|
const TOOL = {
|
|
6
7
|
name: 'prepare_tool_output',
|
|
@@ -19,7 +20,7 @@ function dispatch(message) {
|
|
|
19
20
|
if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') return error(message?.id, -32600, 'Invalid Request');
|
|
20
21
|
if (message.id === undefined) return null;
|
|
21
22
|
if (message.method === 'initialize') return response(message.id, {
|
|
22
|
-
protocolVersion: message.params?.protocolVersion || '2025-06-18', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'sando', version:
|
|
23
|
+
protocolVersion: message.params?.protocolVersion || '2025-06-18', capabilities: { tools: { listChanged: false } }, serverInfo: { name: 'sando', version: PLUGIN_VERSION },
|
|
23
24
|
});
|
|
24
25
|
if (message.method === 'ping') return response(message.id, {});
|
|
25
26
|
if (message.method === 'tools/list') return response(message.id, { tools: [TOOL] });
|
package/src/postinstall.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import readline from 'node:readline/promises';
|
|
|
8
8
|
import { pathToFileURL } from 'node:url';
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
|
-
CONSENT_VERSION, defaultTelemetryConfigPath, enableTelemetry, readTelemetryConfig, TELEMETRY_DETAILS_URL,
|
|
11
|
+
CONSENT_VERSION, defaultTelemetryConfigPath, enableTelemetry, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
|
|
12
12
|
} from './telemetry.mjs';
|
|
13
13
|
|
|
14
14
|
const CONSENT_PROMPT = `Enable anonymous telemetry? Full details at: ${TELEMETRY_DETAILS_URL} [y/N] `;
|
|
@@ -19,6 +19,7 @@ export async function runPostinstall({
|
|
|
19
19
|
} = {}) {
|
|
20
20
|
try {
|
|
21
21
|
if (env.SANDO_SKIP_TELEMETRY_PROMPT) return;
|
|
22
|
+
if (isDoNotTrack(env)) return;
|
|
22
23
|
if (!stdin.isTTY || !stdout.isTTY) return; // CI, --ignore-scripts consumers, piped installs, etc.
|
|
23
24
|
|
|
24
25
|
const configPath = defaultTelemetryConfigPath(env);
|
package/src/proxy.mjs
CHANGED
|
@@ -4,43 +4,56 @@ import { estimateTokens } from './core.mjs';
|
|
|
4
4
|
import { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './context-transform.mjs';
|
|
5
5
|
import { recordProxyRequest } from './proxy-metrics.mjs';
|
|
6
6
|
import {
|
|
7
|
-
closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, readTelemetryConfig,
|
|
7
|
+
closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, incrementCounter, isDoNotTrack, readTelemetryConfig, recordFailure,
|
|
8
8
|
} from './telemetry.mjs';
|
|
9
|
-
|
|
10
|
-
const PLUGIN_VERSION = '0.1';
|
|
9
|
+
import { PLUGIN_VERSION } from './version.mjs';
|
|
11
10
|
|
|
12
11
|
function todayUtc() { return new Date().toISOString().slice(0, 10); }
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
try { config = readTelemetryConfig(configPath); } catch { return; }
|
|
23
|
-
if (!config.enabled) return;
|
|
24
|
-
const cacheWarm = transformed.stats.cacheRewriteRatio !== null;
|
|
13
|
+
function telemetryProvider(provider) {
|
|
14
|
+
if (provider === 'anthropic') return 'anthropic';
|
|
15
|
+
if (provider === 'openai-chat' || provider === 'openai-responses') return 'openai';
|
|
16
|
+
return 'unknown';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Only counts (never request/response content). */
|
|
20
|
+
function recordProxyTelemetry({ env, provider, transformed, beforeText, afterText }) {
|
|
25
21
|
try {
|
|
22
|
+
const configPath = defaultTelemetryConfigPath(env);
|
|
23
|
+
const config = readTelemetryConfig(configPath);
|
|
24
|
+
if (!config.enabled || isDoNotTrack(env)) return;
|
|
26
25
|
const statePaths = defaultTelemetryStatePaths(env);
|
|
27
26
|
incrementCounter({
|
|
28
27
|
statePaths,
|
|
29
28
|
day: todayUtc(),
|
|
30
29
|
event: 'proxy_summary',
|
|
31
|
-
|
|
30
|
+
provider: telemetryProvider(provider),
|
|
31
|
+
mode: 'enforce',
|
|
32
32
|
deltas: {
|
|
33
33
|
rewritesApplied: transformed.changed ? 1 : 0,
|
|
34
34
|
rewritesSkippedCache: transformed.stats.cacheProtectedSkips > 0 ? 1 : 0,
|
|
35
35
|
inputTokensSaved: Math.max(0, estimateTokens(beforeText) - estimateTokens(afterText)),
|
|
36
|
-
cacheHitYes: cacheWarm ? 1 : 0,
|
|
37
|
-
cacheHitNo: cacheWarm ? 0 : 1,
|
|
38
36
|
},
|
|
39
37
|
});
|
|
40
38
|
closeFinishedDays({ statePaths, configPath, day: todayUtc(), pluginVersion: PLUGIN_VERSION });
|
|
41
39
|
} catch { /* telemetry is best-effort and must never affect the proxied response */ }
|
|
42
40
|
}
|
|
43
41
|
|
|
42
|
+
function recordProxyFailure({ env, provider, failureStage }) {
|
|
43
|
+
try {
|
|
44
|
+
const configPath = defaultTelemetryConfigPath(env);
|
|
45
|
+
const config = readTelemetryConfig(configPath);
|
|
46
|
+
if (!config.enabled || isDoNotTrack(env)) return;
|
|
47
|
+
const statePaths = defaultTelemetryStatePaths(env);
|
|
48
|
+
const day = todayUtc();
|
|
49
|
+
recordFailure({
|
|
50
|
+
statePaths, day, event: 'proxy_failure_summary',
|
|
51
|
+
provider: telemetryProvider(provider), failureStage,
|
|
52
|
+
});
|
|
53
|
+
closeFinishedDays({ statePaths, configPath, day, pluginVersion: PLUGIN_VERSION });
|
|
54
|
+
} catch { /* telemetry is best-effort and must never affect the proxied response */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
const DEFAULT_MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
45
58
|
const HOP_BY_HOP_HEADERS = new Set([
|
|
46
59
|
'connection', 'content-length', 'keep-alive', 'proxy-authenticate',
|
|
@@ -176,53 +189,86 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
|
|
|
176
189
|
let lastRequestAt = null;
|
|
177
190
|
|
|
178
191
|
const server = http.createServer(async (request, outgoing) => {
|
|
192
|
+
let failureProvider = null;
|
|
179
193
|
try {
|
|
180
194
|
if (request.method === 'GET' && new URL(request.url, 'http://sando.invalid').pathname === '/health') {
|
|
181
195
|
jsonResponse(outgoing, 200, { schema: 'sando-provider-proxy/v1', status: 'ok', lastStats });
|
|
182
196
|
return;
|
|
183
197
|
}
|
|
184
|
-
|
|
198
|
+
let rawBody;
|
|
199
|
+
try {
|
|
200
|
+
rawBody = await readBody(request, maxBodyBytes);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
recordProxyFailure({ env, provider: null, failureStage: 'input' });
|
|
203
|
+
if (!outgoing.headersSent) jsonResponse(outgoing, error.message === 'request body exceeds proxy limit' ? 413 : 502, { error: 'sando proxy upstream failure' });
|
|
204
|
+
else outgoing.destroy();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
185
207
|
let body = rawBody;
|
|
186
208
|
let recordProvider = null;
|
|
187
209
|
let recordModel = null;
|
|
188
210
|
let recordStats = null;
|
|
189
211
|
if (rawBody.length > 0 && /application\/json/i.test(request.headers['content-type'] ?? '')) {
|
|
212
|
+
let parsed;
|
|
190
213
|
try {
|
|
191
|
-
|
|
214
|
+
parsed = JSON.parse(rawBody.toString('utf8'));
|
|
215
|
+
} catch {
|
|
216
|
+
recordProxyFailure({ env, provider: null, failureStage: 'input' });
|
|
217
|
+
}
|
|
218
|
+
if (parsed) {
|
|
192
219
|
const provider = detectProviderBody(parsed, request.headers);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
220
|
+
failureProvider = provider;
|
|
221
|
+
try {
|
|
222
|
+
if (provider) {
|
|
223
|
+
const now = Date.now();
|
|
224
|
+
const idleMs = lastRequestAt === null ? null : now - lastRequestAt;
|
|
225
|
+
lastRequestAt = now;
|
|
226
|
+
const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
|
|
227
|
+
if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
|
|
228
|
+
recordProxyTelemetry({
|
|
229
|
+
env, provider, transformed,
|
|
230
|
+
beforeText: rawBody.toString('utf8'), afterText: body.toString('utf8'),
|
|
231
|
+
});
|
|
232
|
+
lastStats = { provider, ...transformed.stats, changed: transformed.changed, reasons: transformed.reasons };
|
|
233
|
+
recordProvider = provider;
|
|
234
|
+
recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
|
|
235
|
+
recordStats = transformed.stats;
|
|
236
|
+
if (typeof semanticCompactor === 'function') {
|
|
237
|
+
const candidates = listSemanticCandidates({ provider, body: transformed.body });
|
|
238
|
+
const stats = createSemanticStats(candidates);
|
|
239
|
+
lastStats.semantic = stats;
|
|
240
|
+
setImmediate(() => observeSemanticCandidates({ provider, candidates, semanticCompactor, stats }));
|
|
241
|
+
}
|
|
212
242
|
}
|
|
243
|
+
} catch {
|
|
244
|
+
recordProxyFailure({ env, provider, failureStage: 'optimization' });
|
|
245
|
+
body = rawBody;
|
|
213
246
|
}
|
|
214
|
-
} catch {
|
|
215
|
-
body = rawBody;
|
|
216
247
|
}
|
|
217
248
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
249
|
+
let response;
|
|
250
|
+
try {
|
|
251
|
+
response = await fetch(targetUrl(upstreamUrl, request.url), {
|
|
252
|
+
method: request.method,
|
|
253
|
+
headers: forwardedHeaders(request),
|
|
254
|
+
body: ['GET', 'HEAD'].includes(request.method) ? undefined : body,
|
|
255
|
+
redirect: 'manual',
|
|
256
|
+
});
|
|
257
|
+
} catch {
|
|
258
|
+
recordProxyFailure({ env, provider: failureProvider, failureStage: 'upstream' });
|
|
259
|
+
if (!outgoing.headersSent) jsonResponse(outgoing, 502, { error: 'sando proxy upstream failure' });
|
|
260
|
+
else outgoing.destroy();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
224
263
|
let responseText = '';
|
|
225
|
-
|
|
264
|
+
try {
|
|
265
|
+
await pipeResponse(response, outgoing, metricsPath ? (chunk) => { responseText += chunk; } : undefined);
|
|
266
|
+
} catch {
|
|
267
|
+
recordProxyFailure({ env, provider: failureProvider, failureStage: 'response' });
|
|
268
|
+
if (!outgoing.headersSent) jsonResponse(outgoing, 502, { error: 'sando proxy upstream failure' });
|
|
269
|
+
else outgoing.destroy();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
226
272
|
if (metricsPath && recordProvider) {
|
|
227
273
|
try {
|
|
228
274
|
recordProxyRequest({
|
|
@@ -232,6 +278,7 @@ export async function createProviderProxy({ upstream, host = '127.0.0.1', port =
|
|
|
232
278
|
} catch { /* metrics are best-effort and must never affect the proxied response */ }
|
|
233
279
|
}
|
|
234
280
|
} catch (error) {
|
|
281
|
+
recordProxyFailure({ env, provider: failureProvider, failureStage: 'response' });
|
|
235
282
|
if (!outgoing.headersSent) jsonResponse(outgoing, error.message === 'request body exceeds proxy limit' ? 413 : 502, { error: 'sando proxy upstream failure' });
|
|
236
283
|
else outgoing.destroy();
|
|
237
284
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { createRedactionProfile } from './redaction-profile.mjs';
|
|
5
|
+
|
|
6
|
+
const MAX_CONFIG_BYTES = 64 * 1024;
|
|
7
|
+
const cache = new Map();
|
|
8
|
+
const builtInProfile = createRedactionProfile([]);
|
|
9
|
+
|
|
10
|
+
function lstatIfPresent(target) {
|
|
11
|
+
try {
|
|
12
|
+
return fs.lstatSync(target);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error?.code === 'ENOENT') return null;
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readBoundedFile(configPath, expectedStat) {
|
|
20
|
+
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
|
|
21
|
+
const descriptor = fs.openSync(configPath, flags);
|
|
22
|
+
try {
|
|
23
|
+
const stat = fs.fstatSync(descriptor);
|
|
24
|
+
if (!stat.isFile()) throw new Error(`redaction config is not a regular file: ${configPath}`);
|
|
25
|
+
if (stat.dev !== expectedStat.dev || stat.ino !== expectedStat.ino) {
|
|
26
|
+
throw new Error(`redaction config changed while opening: ${configPath}`);
|
|
27
|
+
}
|
|
28
|
+
if (stat.size > MAX_CONFIG_BYTES) {
|
|
29
|
+
throw new Error(`redaction config exceeds 64 KiB: ${configPath}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const content = Buffer.alloc(MAX_CONFIG_BYTES + 1);
|
|
33
|
+
let bytesRead = 0;
|
|
34
|
+
while (bytesRead < content.length) {
|
|
35
|
+
const count = fs.readSync(descriptor, content, bytesRead, content.length - bytesRead, null);
|
|
36
|
+
if (count === 0) break;
|
|
37
|
+
bytesRead += count;
|
|
38
|
+
}
|
|
39
|
+
if (bytesRead > MAX_CONFIG_BYTES) {
|
|
40
|
+
throw new Error(`redaction config exceeds 64 KiB: ${configPath}`);
|
|
41
|
+
}
|
|
42
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(content.subarray(0, bytesRead));
|
|
43
|
+
} finally {
|
|
44
|
+
fs.closeSync(descriptor);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseConfig(source, configPath) {
|
|
49
|
+
let config;
|
|
50
|
+
try {
|
|
51
|
+
config = JSON.parse(source);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error(`invalid JSON in redaction config ${configPath}: ${error.message}`, { cause: error });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
57
|
+
throw new Error(`invalid redaction config schema: ${configPath}`);
|
|
58
|
+
}
|
|
59
|
+
const keys = Object.keys(config).sort();
|
|
60
|
+
if (keys.length !== 2 || keys[0] !== 'rules' || keys[1] !== 'schema'
|
|
61
|
+
|| config.schema !== 'sando-redaction/v1' || !Array.isArray(config.rules)) {
|
|
62
|
+
throw new Error(`invalid redaction config schema: ${configPath}`);
|
|
63
|
+
}
|
|
64
|
+
return config;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function loadProjectRedactionProfile(cwd) {
|
|
68
|
+
try {
|
|
69
|
+
return loadProjectRedactionProfileUnsafe(cwd);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error?.code === 'SANDO_REDACTION_CONFIG') throw error;
|
|
72
|
+
const wrapped = new Error(error instanceof Error ? error.message : String(error), { cause: error });
|
|
73
|
+
wrapped.code = 'SANDO_REDACTION_CONFIG';
|
|
74
|
+
throw wrapped;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function loadProjectRedactionProfileUnsafe(cwd) {
|
|
79
|
+
const configDirectory = path.resolve(cwd, '.sando');
|
|
80
|
+
const directoryStat = lstatIfPresent(configDirectory);
|
|
81
|
+
if (!directoryStat) return { profile: builtInProfile, path: null };
|
|
82
|
+
if (directoryStat.isSymbolicLink()) {
|
|
83
|
+
throw new Error(`redaction config directory must not be a symlink: ${configDirectory}`);
|
|
84
|
+
}
|
|
85
|
+
if (!directoryStat.isDirectory()) {
|
|
86
|
+
throw new Error(`redaction config directory is not a directory: ${configDirectory}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const configPath = path.join(configDirectory, 'redaction.json');
|
|
90
|
+
const stat = lstatIfPresent(configPath);
|
|
91
|
+
if (!stat) return { profile: builtInProfile, path: null };
|
|
92
|
+
if (stat.isSymbolicLink()) throw new Error(`redaction config must not be a symlink: ${configPath}`);
|
|
93
|
+
if (!stat.isFile()) throw new Error(`redaction config is not a regular file: ${configPath}`);
|
|
94
|
+
if (stat.size > MAX_CONFIG_BYTES) throw new Error(`redaction config exceeds 64 KiB: ${configPath}`);
|
|
95
|
+
|
|
96
|
+
const cached = cache.get(configPath);
|
|
97
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.result;
|
|
98
|
+
|
|
99
|
+
const source = readBoundedFile(configPath, stat);
|
|
100
|
+
const config = parseConfig(source, configPath);
|
|
101
|
+
const result = { profile: createRedactionProfile(config.rules), path: configPath };
|
|
102
|
+
cache.set(configPath, { mtimeMs: stat.mtimeMs, size: stat.size, result });
|
|
103
|
+
return result;
|
|
104
|
+
}
|