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.
@@ -0,0 +1,163 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const BUILT_IN_MATCHERS = Object.freeze([
4
+ [/-----BEGIN [A-Z ]+ KEY-----[\s\S]*?-----END [A-Z ]+ KEY-----/g, '[REDACTED PRIVATE KEY]'],
5
+ [/(authorization\s*[:=]\s*(?!(?:bearer\s+)?\[REDACTED(?: (?:PRIVATE KEY|TOKEN))?\](?=$|[\s,"'}]))(?:bearer\s+)?)[^\s,"'}]+/gi,
6
+ (_match, prefix) => `${prefix}[REDACTED]`],
7
+ [/(["']?(?:api[_-]?key|access[_-]?token|password|secret|private[_-]?key)["']?\s*[:=]\s*["']?)(?!\[REDACTED(?: (?:PRIVATE KEY|TOKEN))?\](?=$|[\s,"'}]))[^\s,"'}]+/gi,
8
+ (_match, prefix) => `${prefix}[REDACTED]`],
9
+ [/\b(?:sk|rk)-[A-Za-z0-9_-]{12,}\b/g, '[REDACTED TOKEN]'],
10
+ [/\bgh[pousr]_[A-Za-z0-9_-]{12,}\b/g, '[REDACTED TOKEN]'],
11
+ [/\bgithub_pat_[A-Za-z0-9_-]{20,}\b/g, '[REDACTED TOKEN]'],
12
+ [/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED TOKEN]'],
13
+ ]);
14
+ const PROFILE_SCHEMA = 'sando-redaction-profile-v1';
15
+ const MAX_CUSTOM_RULES = 100;
16
+ const MAX_NAME_LENGTH = 64;
17
+ const MAX_TOKEN_LENGTH = 4096;
18
+
19
+ function escapeRegExp(value) {
20
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
+ }
22
+
23
+ function compareCanonical(left, right) {
24
+ const a = JSON.stringify(left);
25
+ const b = JSON.stringify(right);
26
+ return a < b ? -1 : a > b ? 1 : 0;
27
+ }
28
+
29
+ function normalizeRules(customRules) {
30
+ if (!Array.isArray(customRules)) throw new TypeError('customRules must be an array');
31
+ if (customRules.length > MAX_CUSTOM_RULES) throw new TypeError('customRules must contain at most 100 rules');
32
+ const identities = new Set();
33
+ const normalized = customRules.map((rule) => {
34
+ if (!rule || typeof rule !== 'object' || Array.isArray(rule)
35
+ || ![Object.prototype, null].includes(Object.getPrototypeOf(rule))) {
36
+ throw new TypeError('each custom rule must be a plain object');
37
+ }
38
+ let result;
39
+ let identity;
40
+ if (rule.type === 'assignment-key') {
41
+ const allowed = new Set(['type', 'key']);
42
+ if (Reflect.ownKeys(rule).some((field) => typeof field !== 'string' || !allowed.has(field))
43
+ || typeof rule.key !== 'string'
44
+ || !new RegExp(`^[A-Za-z0-9][A-Za-z0-9_.-]{0,${MAX_NAME_LENGTH - 1}}$`).test(rule.key)) {
45
+ throw new TypeError('invalid assignment-key rule');
46
+ }
47
+ const key = rule.key.toLowerCase();
48
+ result = { type: rule.type, key };
49
+ identity = `${rule.type}:${key}`;
50
+ } else if (rule.type === 'token-prefix') {
51
+ const allowed = new Set(['type', 'prefix', 'minLength', 'maxLength']);
52
+ if (Reflect.ownKeys(rule).some((field) => typeof field !== 'string' || !allowed.has(field))
53
+ || typeof rule.prefix !== 'string'
54
+ || !new RegExp(`^[\\x21-\\x7e]{1,${MAX_NAME_LENGTH}}$`).test(rule.prefix)) {
55
+ throw new TypeError('invalid token-prefix rule');
56
+ }
57
+ const minLength = Object.hasOwn(rule, 'minLength') ? rule.minLength : 12;
58
+ const maxLength = Object.hasOwn(rule, 'maxLength') ? rule.maxLength : null;
59
+ if (!Number.isSafeInteger(minLength) || minLength < 1 || minLength > MAX_TOKEN_LENGTH
60
+ || (maxLength !== null
61
+ && (!Number.isSafeInteger(maxLength) || maxLength < minLength || maxLength > MAX_TOKEN_LENGTH))
62
+ || (Object.hasOwn(rule, 'maxLength') && rule.maxLength === null)) {
63
+ throw new TypeError('invalid token-prefix lengths');
64
+ }
65
+ result = { type: rule.type, prefix: rule.prefix, minLength, maxLength };
66
+ identity = `${rule.type}:${rule.prefix}`;
67
+ } else {
68
+ throw new TypeError('unsupported custom rule type');
69
+ }
70
+ if (identities.has(identity)) throw new TypeError('duplicate custom rule');
71
+ identities.add(identity);
72
+ return result;
73
+ });
74
+ return normalized.sort(compareCanonical);
75
+ }
76
+
77
+ function customMatchers(rules) {
78
+ const assignments = [];
79
+ const tokens = [];
80
+ for (const rule of rules) {
81
+ if (rule.type === 'assignment-key') {
82
+ const key = escapeRegExp(rule.key);
83
+ assignments.push([
84
+ new RegExp(`(?<![A-Za-z0-9_.-])(["']?${key}["']?\\s*[:=]\\s*["']?)(?!\\[REDACTED\\](?=$|[\\s,"'}]))[^\\s,"'}]+`, 'gi'),
85
+ (_match, prefix) => `${prefix}[REDACTED]`,
86
+ ]);
87
+ } else {
88
+ const maximum = rule.maxLength === null ? '' : rule.maxLength;
89
+ tokens.push([
90
+ new RegExp(`(?<![A-Za-z0-9_-])${escapeRegExp(rule.prefix)}[A-Za-z0-9_-]{${rule.minLength},${maximum}}(?![A-Za-z0-9_-])`, 'g'),
91
+ '[REDACTED]',
92
+ ]);
93
+ }
94
+ }
95
+ return { assignments, tokens };
96
+ }
97
+
98
+ function applyMatchers(text, matchers) {
99
+ let count = 0;
100
+ for (const [pattern, replacement] of matchers) {
101
+ text = text.replace(pattern, (...args) => {
102
+ count += 1;
103
+ return typeof replacement === 'function' ? replacement(...args) : replacement;
104
+ });
105
+ }
106
+ return { text, count };
107
+ }
108
+
109
+ export function createRedactionProfile(customRules = []) {
110
+ const rules = normalizeRules(customRules);
111
+ const custom = customMatchers(rules);
112
+ const matchers = [
113
+ ...BUILT_IN_MATCHERS.slice(0, 3),
114
+ ...custom.assignments,
115
+ ...BUILT_IN_MATCHERS.slice(3),
116
+ ...custom.tokens,
117
+ ];
118
+ const digest = `sha256:${createHash('sha256')
119
+ .update(JSON.stringify({ schema: PROFILE_SCHEMA, customRules: rules }))
120
+ .digest('hex')}`;
121
+ const redact = (text) => {
122
+ if (typeof text !== 'string') throw new TypeError('text must be a string');
123
+ return applyMatchers(text, matchers);
124
+ };
125
+ const redactStructured = (value) => {
126
+ const ancestors = new Set();
127
+ const visit = (item) => {
128
+ if (typeof item === 'string') {
129
+ const result = redact(item);
130
+ return { value: result.text, count: result.count };
131
+ }
132
+ if (item === null || typeof item === 'boolean' || typeof item === 'number') {
133
+ return { value: item, count: 0 };
134
+ }
135
+ if (typeof item !== 'object'
136
+ || (!Array.isArray(item) && ![Object.prototype, null].includes(Object.getPrototypeOf(item)))) {
137
+ throw new TypeError('value must contain only plain objects, arrays, and JSON primitives');
138
+ }
139
+ if (ancestors.has(item)) throw new TypeError('value must not be cyclic');
140
+ ancestors.add(item);
141
+ let count = 0;
142
+ const entries = Object.entries(item).map(([key, child]) => {
143
+ const result = visit(child);
144
+ count += result.count;
145
+ return [key, result.value];
146
+ });
147
+ ancestors.delete(item);
148
+ return {
149
+ value: Array.isArray(item) ? entries.map(([, child]) => child) : Object.fromEntries(entries),
150
+ count,
151
+ };
152
+ };
153
+ return visit(value);
154
+ };
155
+ return {
156
+ digest,
157
+ redact,
158
+ redactStructured,
159
+ hasSecret(text) {
160
+ return redact(text).count > 0;
161
+ },
162
+ };
163
+ }
@@ -1,21 +1,17 @@
1
+ import { createRedactionProfile } from './redaction-profile.mjs';
2
+
3
+ const builtInProfile = createRedactionProfile();
4
+
5
+ export { createRedactionProfile };
6
+
1
7
  export function redact(text) {
2
- let count = 0;
3
- const replace = (pattern, replacement) => {
4
- text = text.replace(pattern, (...args) => {
5
- count += 1;
6
- return typeof replacement === 'function' ? replacement(...args) : replacement;
7
- });
8
- };
9
- replace(/-----BEGIN [A-Z ]+ KEY-----[\s\S]*?-----END [A-Z ]+ KEY-----/g, '[REDACTED PRIVATE KEY]');
10
- replace(/\b(?:sk|rk)-[A-Za-z0-9_-]{12,}\b/g, '[REDACTED TOKEN]');
11
- replace(/\bgh[pousr]_[A-Za-z0-9_-]{12,}\b/g, '[REDACTED TOKEN]');
12
- replace(/\bgithub_pat_[A-Za-z0-9_-]{20,}\b/g, '[REDACTED TOKEN]');
13
- replace(/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED TOKEN]');
14
- replace(/(authorization\s*[:=]\s*(?:bearer\s+)?)[^\s,"'}]+/gi, (_match, prefix) => `${prefix}[REDACTED]`);
15
- replace(/(["']?(?:api[_-]?key|access[_-]?token|password|secret|private[_-]?key)["']?\s*[:=]\s*["']?)[^\s,"'}]+/gi, (_match, prefix) => `${prefix}[REDACTED]`);
16
- return { text, count };
8
+ return builtInProfile.redact(text);
9
+ }
10
+
11
+ export function redactStructured(value, enabled = true) {
12
+ return enabled ? builtInProfile.redactStructured(value).value : value;
17
13
  }
18
14
 
19
15
  export function hasSecret(text) {
20
- return /(?:authorization|api[_-]?key|access[_-]?token|password|secret|private[_-]?key)\s*[:=]\s*(?!\[REDACTED\])\S+|-----BEGIN [A-Z ]+ KEY-----|\b(?:sk|rk)-[A-Za-z0-9_-]{12,}\b|\bgh[pousr]_[A-Za-z0-9_-]{12,}\b|\bgithub_pat_[A-Za-z0-9_-]{20,}\b|\bAKIA[0-9A-Z]{16}\b/i.test(text);
16
+ return builtInProfile.hasSecret(text);
21
17
  }
@@ -60,7 +60,9 @@ export function buildSemanticPrompt({ provider, model, toolName, text, requiredF
60
60
  ].join('\n');
61
61
  }
62
62
 
63
- export function validateSemanticSummary({ originalText, summary, requiredFacts = [], maxSummaryRatio = DEFAULT_POLICY.maxSummaryRatio } = {}) {
63
+ export function validateSemanticSummary({
64
+ originalText, summary, requiredFacts = [], maxSummaryRatio = DEFAULT_POLICY.maxSummaryRatio, redactionProfile,
65
+ } = {}) {
64
66
  if (typeof originalText !== 'string' || typeof summary !== 'string') {
65
67
  return { valid: false, reason: 'invalid-text' };
66
68
  }
@@ -77,7 +79,9 @@ export function validateSemanticSummary({ originalText, summary, requiredFacts =
77
79
  }
78
80
  const missing = required.find((fact) => !summary.includes(fact));
79
81
  if (missing) return { valid: false, reason: 'missing-required-fact', missing, inputTokens, outputTokens };
80
- if (hasSecret(summary)) return { valid: false, reason: 'secret-detected', inputTokens, outputTokens };
82
+ if ((redactionProfile ? redactionProfile.hasSecret(summary) : hasSecret(summary))) {
83
+ return { valid: false, reason: 'secret-detected', inputTokens, outputTokens };
84
+ }
81
85
  return { valid: true, inputTokens, outputTokens };
82
86
  }
83
87
 
@@ -111,12 +115,15 @@ async function withTimeout(complete, request, timeoutMs) {
111
115
  }
112
116
  }
113
117
 
114
- export function createSemanticCompactor({ complete, cache = new Map(), policy } = {}) {
118
+ export function createSemanticCompactor({ complete, cache = new Map(), policy, redactionProfile } = {}) {
115
119
  if (typeof complete !== 'function') throw new TypeError('semantic compactor complete callback is required');
116
120
  if (!cache || typeof cache.get !== 'function' || typeof cache.set !== 'function') {
117
121
  throw new TypeError('semantic compactor cache must implement get and set');
118
122
  }
119
123
  const options = validatePolicy(policy);
124
+ if (redactionProfile && (typeof redactionProfile.redact !== 'function' || typeof redactionProfile.hasSecret !== 'function')) {
125
+ throw new TypeError('redactionProfile is invalid');
126
+ }
120
127
 
121
128
  return async function compact({ provider, model, toolName, text, historical = true, isError = false, requiredFacts = [] } = {}) {
122
129
  if (typeof text !== 'string') throw new TypeError('semantic compactor text must be a string');
@@ -136,8 +143,8 @@ export function createSemanticCompactor({ complete, cache = new Map(), policy }
136
143
  if (isError) return { ...base, status: 'skipped', reason: 'error-result' };
137
144
  if (inputTokens < options.minInputTokens) return { ...base, status: 'skipped', reason: 'below-threshold' };
138
145
 
139
- const safe = redact(text);
140
- const safeFacts = required.map((fact) => redact(fact).text);
146
+ const safe = redactionProfile ? redactionProfile.redact(text) : redact(text);
147
+ const safeFacts = required.map((fact) => (redactionProfile ? redactionProfile.redact(fact) : redact(fact)).text);
141
148
  const prompt = buildSemanticPrompt({ provider, model, toolName, text: safe.text, requiredFacts: safeFacts });
142
149
  const key = cacheKey({ provider, model, toolName, text: safe.text, requiredFacts: safeFacts });
143
150
  const started = Date.now();
@@ -147,6 +154,7 @@ export function createSemanticCompactor({ complete, cache = new Map(), policy }
147
154
  const validation = validateSemanticSummary({
148
155
  originalText: text, summary: cached.summary, requiredFacts: required,
149
156
  maxSummaryRatio: options.maxSummaryRatio,
157
+ redactionProfile,
150
158
  });
151
159
  if (validation.valid) {
152
160
  const grossSavedTokens = inputTokens - validation.outputTokens;
@@ -191,6 +199,7 @@ export function createSemanticCompactor({ complete, cache = new Map(), policy }
191
199
  const validation = validateSemanticSummary({
192
200
  originalText: text, summary, requiredFacts: required,
193
201
  maxSummaryRatio: options.maxSummaryRatio,
202
+ redactionProfile,
194
203
  });
195
204
  if (!validation.valid) return { ...base, reason: validation.reason, latencyMs: Date.now() - started };
196
205
 
@@ -1,15 +1,27 @@
1
1
  import path from 'node:path';
2
2
 
3
- import { defaultTelemetryConfigPath, readTelemetryConfig, TELEMETRY_DETAILS_URL } from './telemetry.mjs';
3
+ import {
4
+ closeFinishedDays, defaultTelemetryConfigPath, defaultTelemetryStatePaths, isDoNotTrack, readTelemetryConfig, TELEMETRY_DETAILS_URL,
5
+ } from './telemetry.mjs';
6
+ import { PLUGIN_VERSION } from './version.mjs';
4
7
 
5
8
  export function runSessionStart({
6
9
  env = process.env,
7
10
  stdout = process.stdout,
8
11
  rootEnv = 'PLUGIN_ROOT',
12
+ spawnImpl,
13
+ configPath = defaultTelemetryConfigPath(env),
14
+ statePaths = defaultTelemetryStatePaths(env),
9
15
  } = {}) {
10
16
  try {
11
- const config = readTelemetryConfig(defaultTelemetryConfigPath(env));
12
- if (config.prompted_consent_version > 0) {
17
+ const config = readTelemetryConfig(configPath);
18
+ if (config.enabled && !isDoNotTrack(env)) {
19
+ closeFinishedDays({
20
+ statePaths, configPath, day: new Date().toISOString().slice(0, 10),
21
+ pluginVersion: PLUGIN_VERSION, ...(spawnImpl ? { spawnImpl } : {}),
22
+ });
23
+ }
24
+ if (isDoNotTrack(env) || config.prompted_consent_version > 0) {
13
25
  stdout.write('{}\n');
14
26
  return;
15
27
  }
@@ -6,7 +6,7 @@ import { pathToFileURL } from 'node:url';
6
6
 
7
7
  import {
8
8
  defaultTelemetryConfigPath, defaultTelemetryStatePaths,
9
- disableTelemetry, enableTelemetry, flushQueue, previewNextUpload, statusTelemetry, TELEMETRY_DETAILS_URL,
9
+ disableTelemetry, enableTelemetry, flushQueue, isDoNotTrack, previewNextUpload, statusTelemetry, TELEMETRY_DETAILS_URL,
10
10
  } from './telemetry.mjs';
11
11
 
12
12
  const USAGE = 'Usage: sando telemetry <status|enable|disable [--purge]|preview|flush>\n';
@@ -26,13 +26,26 @@ export async function runTelemetryCli({
26
26
  try {
27
27
  if (command === 'status') {
28
28
  const config = statusTelemetry(configPath);
29
+ if (isDoNotTrack(env)) {
30
+ stdout.write('telemetry: disabled by DO_NOT_TRACK\n');
31
+ return { ...config, enabled: false };
32
+ }
29
33
  stdout.write(`telemetry: ${config.enabled ? 'enabled' : 'disabled'}\n`);
30
34
  return config;
31
35
  }
32
36
  if (command === 'enable') {
37
+ if (isDoNotTrack(env)) {
38
+ stderr.write('sando telemetry: DO_NOT_TRACK is set; telemetry remains disabled\n');
39
+ return { ...statusTelemetry(configPath), enabled: false, exitCode: 1 };
40
+ }
33
41
  if (!interactive) {
34
42
  stderr.write('sando telemetry: enable requires an interactive session\n');
35
- return enableTelemetry({ configPath, interactive: false });
43
+ return { ...statusTelemetry(configPath), exitCode: 1 };
44
+ }
45
+ const current = statusTelemetry(configPath);
46
+ if (current.enabled) {
47
+ stdout.write('telemetry already enabled.\n');
48
+ return current;
36
49
  }
37
50
  const answer = await prompt(CONSENT_PROMPT);
38
51
  const result = enableTelemetry({
@@ -54,6 +67,10 @@ export async function runTelemetryCli({
54
67
  return preview;
55
68
  }
56
69
  if (command === 'flush') {
70
+ if (isDoNotTrack(env)) {
71
+ stdout.write('telemetry disabled by DO_NOT_TRACK; nothing to flush.\n');
72
+ return { sent: 0 };
73
+ }
57
74
  const config = statusTelemetry(configPath);
58
75
  if (!config.enabled) {
59
76
  stdout.write('telemetry is disabled; nothing to flush.\n');
@@ -72,5 +89,6 @@ export async function runTelemetryCli({
72
89
  }
73
90
 
74
91
  if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
75
- await runTelemetryCli();
92
+ const result = await runTelemetryCli();
93
+ if (result?.exitCode) process.exitCode = result.exitCode;
76
94
  }
@@ -5,19 +5,19 @@
5
5
  import path from 'node:path';
6
6
  import { pathToFileURL } from 'node:url';
7
7
 
8
- import { flushQueue, readTelemetryConfig } from './telemetry.mjs';
8
+ import { flushQueue, isDoNotTrack, readTelemetryConfig } from './telemetry.mjs';
9
9
 
10
10
  function option(argv, name) {
11
11
  const index = argv.indexOf(`--${name}`);
12
12
  return index === -1 ? undefined : argv[index + 1];
13
13
  }
14
14
 
15
- export async function runTelemetryFlushEntry({ argv = process.argv.slice(2) } = {}) {
15
+ export async function runTelemetryFlushEntry({ argv = process.argv.slice(2), env = process.env } = {}) {
16
16
  const queuePath = option(argv, 'queue');
17
17
  const configPath = option(argv, 'config');
18
18
  if (!queuePath || !configPath) throw new Error('telemetry-flush-entry requires --queue and --config');
19
19
  const config = readTelemetryConfig(configPath);
20
- if (!config.enabled) return { sent: 0 };
20
+ if (!config.enabled || isDoNotTrack(env)) return { sent: 0 };
21
21
  return flushQueue({ statePaths: { queue: queuePath }, endpoint: config.endpoint });
22
22
  }
23
23