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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandoichi",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Reduce repeated tool-output context in Claude Code and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "yuzushi",
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 { redact } from './secret-redaction.mjs';
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 redacted = normalizedPolicy.redact ? redact(input) : { text: input, count: 0 };
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 = { inline, route: route.route, reason: route.source, policyVersion: ROUTING_POLICY_VERSION, stats };
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, stats: optimization.stats,
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) — see docs/plans/2026-08-25-sando-telemetry-design.md.
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 === 'observe' ? 'observe' : 'enforce',
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
- const optimization = optimizeToolOutput({ toolName: event.toolName, toolInput: event.toolInput, output: event.output, cwd: event.cwd, policy });
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
  }
@@ -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: '0.1.0' },
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] });
@@ -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
- /** Only counts (never request/response content) — see docs/plans/2026-08-25-sando-telemetry-design.md.
15
- * `host` here is the provider request shape (`anthropic`/`openai`), which does not map cleanly to
16
- * Sando's `claude`/`codex` telemetry host enum; the proxy fronts both hosts equally, so it reports
17
- * under a fixed `claude` label — this is the one place the design doc's host/provider split is
18
- * approximate, flagged for the design doc rather than silently assumed. */
19
- function recordProxyTelemetry({ env, transformed, beforeText, afterText }) {
20
- const configPath = defaultTelemetryConfigPath(env);
21
- let config;
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
- host: 'claude',
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
- const rawBody = await readBody(request, maxBodyBytes);
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
- const parsed = JSON.parse(rawBody.toString('utf8'));
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
- if (provider) {
194
- const now = Date.now();
195
- const idleMs = lastRequestAt === null ? null : now - lastRequestAt;
196
- lastRequestAt = now;
197
- const transformed = transformProviderRequest({ provider, body: parsed, policy, idleMs });
198
- if (transformed.changed) body = Buffer.from(JSON.stringify(transformed.body));
199
- recordProxyTelemetry({
200
- env, transformed,
201
- beforeText: rawBody.toString('utf8'), afterText: body.toString('utf8'),
202
- });
203
- lastStats = { provider, ...transformed.stats, changed: transformed.changed, reasons: transformed.reasons };
204
- recordProvider = provider;
205
- recordModel = typeof parsed?.model === 'string' ? parsed.model : null;
206
- recordStats = transformed.stats;
207
- if (typeof semanticCompactor === 'function') {
208
- const candidates = listSemanticCandidates({ provider, body: transformed.body });
209
- const stats = createSemanticStats(candidates);
210
- lastStats.semantic = stats;
211
- setImmediate(() => observeSemanticCandidates({ provider, candidates, semanticCompactor, stats }));
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
- const response = await fetch(targetUrl(upstreamUrl, request.url), {
219
- method: request.method,
220
- headers: forwardedHeaders(request),
221
- body: ['GET', 'HEAD'].includes(request.method) ? undefined : body,
222
- redirect: 'manual',
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
- await pipeResponse(response, outgoing, metricsPath ? (chunk) => { responseText += chunk; } : undefined);
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
+ }