sandoichi 0.6.0 → 0.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandoichi",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Bound repeated tool-output context in Claude Code and Codex with deterministic local routing and provider accounting.",
5
5
  "license": "MIT",
6
6
  "author": "yuzushi",
@@ -4,6 +4,23 @@ import path from 'node:path';
4
4
  export const DEFAULT_ARTIFACT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
5
5
  export const DEFAULT_ARTIFACT_MAX_BYTES = 64 * 1024 * 1024;
6
6
 
7
+ export function reuseArtifact(destination, expectedContent) {
8
+ const { O_NOFOLLOW, O_NONBLOCK } = fs.constants;
9
+ if (!Number.isInteger(O_NOFOLLOW) || !Number.isInteger(O_NONBLOCK)) {
10
+ throw new Error('artifact storage requires no-follow open support');
11
+ }
12
+ const flags = fs.constants.O_RDONLY | O_NOFOLLOW | O_NONBLOCK;
13
+ const handle = fs.openSync(destination, flags);
14
+ try {
15
+ const stat = fs.fstatSync(handle);
16
+ if (!stat.isFile() || stat.nlink !== 1) throw new Error('artifact file is unsafe');
17
+ if (fs.readFileSync(handle, 'utf8') !== expectedContent) throw new Error('artifact content differs');
18
+ fs.fchmodSync(handle, 0o600);
19
+ } finally {
20
+ fs.closeSync(handle);
21
+ }
22
+ }
23
+
7
24
  function validNumber(value, name) {
8
25
  if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${name} is invalid`);
9
26
  return value;
@@ -2,6 +2,8 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
 
5
+ import { reuseArtifact } from './artifact-lifecycle.mjs';
6
+
5
7
  const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
6
8
  const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
7
9
 
@@ -70,11 +72,11 @@ export function persistHistoryArtifact(artifact) {
70
72
  fs.writeFileSync(temporary, artifact.content, { flag: 'wx', mode: 0o600 });
71
73
  try { fs.linkSync(temporary, destination); }
72
74
  catch (error) {
73
- if (error?.code !== 'EEXIST' || fs.readFileSync(destination, 'utf8') !== artifact.content) throw error;
75
+ if (error?.code !== 'EEXIST') throw error;
76
+ reuseArtifact(destination, artifact.content);
74
77
  }
75
78
  } finally {
76
79
  fs.rmSync(temporary, { force: true });
77
80
  }
78
- fs.chmodSync(destination, 0o600);
79
81
  return artifact;
80
82
  }
package/src/hook-cli.mjs CHANGED
@@ -3,7 +3,7 @@ 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 { cleanupArtifacts } from './artifact-lifecycle.mjs';
6
+ import { cleanupArtifacts, reuseArtifact } from './artifact-lifecycle.mjs';
7
7
  import { loadProjectRedactionProfile } from './redaction-config.mjs';
8
8
  import { defaultMetricsPath, recordMetrics } from './metrics.mjs';
9
9
  import {
@@ -86,12 +86,12 @@ function artifactPath(cwd, artifact) {
86
86
  fs.writeFileSync(temporary, artifact.content, { flag: 'wx', mode: 0o600 });
87
87
  try { fs.linkSync(temporary, destination); }
88
88
  catch (error) {
89
- if (error?.code !== 'EEXIST' || fs.readFileSync(destination, 'utf8') !== artifact.content) throw error;
89
+ if (error?.code !== 'EEXIST') throw error;
90
+ reuseArtifact(destination, artifact.content);
90
91
  }
91
92
  } finally {
92
93
  fs.rmSync(temporary, { force: true });
93
94
  }
94
- fs.chmodSync(destination, 0o600);
95
95
  cleanupArtifacts(directory, { preserveName: name });
96
96
  if (!artifactPresent(destination)) throw new Error('artifact storage limit removed the new artifact');
97
97
  return path.posix.join('.sando/sando', 'artifacts', name);
@@ -2,10 +2,6 @@ import { createHash } from 'node:crypto';
2
2
 
3
3
  const BUILT_IN_MATCHERS = Object.freeze([
4
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
5
  [/\b(?:sk|rk)-[A-Za-z0-9_-]{12,}\b/g, '[REDACTED TOKEN]'],
10
6
  [/\bgh[pousr]_[A-Za-z0-9_-]{12,}\b/g, '[REDACTED TOKEN]'],
11
7
  [/\bgithub_pat_[A-Za-z0-9_-]{20,}\b/g, '[REDACTED TOKEN]'],
@@ -20,6 +16,55 @@ function escapeRegExp(value) {
20
16
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
21
17
  }
22
18
 
19
+ const REDACTION_WORD = 'REDACTED';
20
+ const REDACTION_PLACEHOLDER = `[${REDACTION_WORD}]`;
21
+ const REDACTION_VALUE = String.raw`\[${REDACTION_WORD}(?: (?:PRIVATE KEY|TOKEN))?\]`;
22
+ const ASSIGNMENT_VALUE = String.raw`${REDACTION_VALUE}(?=$|[\s,"'}])|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\\n])*(?=\n|$)|'(?:\\.|[^'\\\n])*(?=\n|$)|[^\s,"'}]+`;
23
+
24
+ function isRedactionPlaceholder(value) {
25
+ const unquoted = value.length >= 2 && ((value.startsWith('"') && value.endsWith('"'))
26
+ || (value.startsWith("'") && value.endsWith("'"))) ? value.slice(1, -1) : value;
27
+ return new RegExp(`^${REDACTION_VALUE}$`).test(unquoted);
28
+ }
29
+
30
+ function redactAssignment(prefix, value) {
31
+ if (isRedactionPlaceholder(value)) return `${prefix}${value}`;
32
+ const quote = value[0] === '"' || value[0] === "'" ? value[0] : '';
33
+ return `${prefix}${quote}${REDACTION_PLACEHOLDER}${quote}`;
34
+ }
35
+
36
+ function assignmentMatcher(keyPattern) {
37
+ return [
38
+ new RegExp(`(${keyPattern}\\s*[:=]\\s*)(${ASSIGNMENT_VALUE})`, 'gi'),
39
+ (_match, prefix, value) => redactAssignment(prefix, value),
40
+ ];
41
+ }
42
+
43
+ const AUTHORIZATION_MATCHER = [
44
+ new RegExp(
45
+ '(["\\\']?authorization["\\\']?\\s*[:=]\\s*(?:(?:bearer|basic)\\s+)?' + ')(' + ASSIGNMENT_VALUE + ')',
46
+ 'gi',
47
+ ),
48
+ (_match, prefix, value) => redactAssignment(prefix, value),
49
+ ];
50
+ const AUTHORIZATION_FIELD_MATCHER = [
51
+ new RegExp(
52
+ '(["\\\']?authorization["\\\']?\\s*[:=]\\s*)(?!(?:\\s*)(?:bearer|basic)\\s+)('
53
+ + '"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|[^\\r\\n]+)',
54
+ 'gi',
55
+ ),
56
+ (_match, prefix, value) => {
57
+ const leading = value.match(/^\s*/u)?.[0] ?? '';
58
+ const withoutLeading = value.slice(leading.length);
59
+ const trailing = withoutLeading.match(/\s*$/u)?.[0] ?? '';
60
+ const body = trailing ? withoutLeading.slice(0, -trailing.length) : withoutLeading;
61
+ return `${prefix}${leading}${redactAssignment('', body)}${trailing}`;
62
+ },
63
+ ];
64
+ const BUILT_IN_ASSIGNMENT_MATCHER = assignmentMatcher(
65
+ String.raw`["']?(?:api[_-]?key|access[_-]?token|password|secret|private[_-]?key)["']?`,
66
+ );
67
+
23
68
  function compareCanonical(left, right) {
24
69
  const a = JSON.stringify(left);
25
70
  const b = JSON.stringify(right);
@@ -79,11 +124,10 @@ function customMatchers(rules) {
79
124
  const tokens = [];
80
125
  for (const rule of rules) {
81
126
  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
- ]);
127
+ const key = escapeRegExp(rule.key);
128
+ assignments.push(assignmentMatcher(
129
+ String.raw`(?<![A-Za-z0-9_.-])["']?${key}["']?`,
130
+ ));
87
131
  } else {
88
132
  const maximum = rule.maxLength === null ? '' : rule.maxLength;
89
133
  tokens.push([
@@ -99,8 +143,9 @@ function applyMatchers(text, matchers) {
99
143
  let count = 0;
100
144
  for (const [pattern, replacement] of matchers) {
101
145
  text = text.replace(pattern, (...args) => {
102
- count += 1;
103
- return typeof replacement === 'function' ? replacement(...args) : replacement;
146
+ const next = typeof replacement === 'function' ? replacement(...args) : replacement;
147
+ if (next !== args[0]) count += 1;
148
+ return next;
104
149
  });
105
150
  }
106
151
  return { text, count };
@@ -110,9 +155,12 @@ export function createRedactionProfile(customRules = []) {
110
155
  const rules = normalizeRules(customRules);
111
156
  const custom = customMatchers(rules);
112
157
  const matchers = [
113
- ...BUILT_IN_MATCHERS.slice(0, 3),
158
+ BUILT_IN_MATCHERS[0],
159
+ AUTHORIZATION_FIELD_MATCHER,
160
+ AUTHORIZATION_MATCHER,
161
+ BUILT_IN_ASSIGNMENT_MATCHER,
114
162
  ...custom.assignments,
115
- ...BUILT_IN_MATCHERS.slice(3),
163
+ ...BUILT_IN_MATCHERS.slice(1),
116
164
  ...custom.tokens,
117
165
  ];
118
166
  const digest = `sha256:${createHash('sha256')
@@ -5,6 +5,8 @@ import { redact, hasSecret } from './secret-redaction.mjs';
5
5
 
6
6
  export const SEMANTIC_SUMMARY_SCHEMA = 'sando-semantic-summary/v1';
7
7
 
8
+ // Ordered extraction guarantees grounding in source text, not preservation of every omitted fact.
9
+
8
10
  const DEFAULT_POLICY = Object.freeze({
9
11
  minInputTokens: 8000,
10
12
  maxSummaryRatio: 0.2,
@@ -48,7 +50,7 @@ export function buildSemanticPrompt({ provider, model, toolName, text, requiredF
48
50
  const required = facts(requiredFacts);
49
51
  return [
50
52
  `Schema: ${SEMANTIC_SUMMARY_SCHEMA}`,
51
- 'Summarize the historical tool result for a coding agent.',
53
+ 'Return a concise extractive summary using only complete source lines in their original order; do not paraphrase or invent clauses.',
52
54
  'Keep exact paths, identifiers, errors, numbers, negations, and every required fact.',
53
55
  'Preserved facts must be copied verbatim from the tool result or required-facts list; do not invent or estimate counts.',
54
56
  'Return JSON only with schema, summary, and preservedFacts fields.',
@@ -82,6 +84,15 @@ export function validateSemanticSummary({
82
84
  if ((redactionProfile ? redactionProfile.hasSecret(summary) : hasSecret(summary))) {
83
85
  return { valid: false, reason: 'secret-detected', inputTokens, outputTokens };
84
86
  }
87
+ const redactedOriginal = redactionProfile ? redactionProfile.redact(originalText).text : redact(originalText).text;
88
+ const sourceLines = redactedOriginal.split(/\r?\n/);
89
+ let sourceIndex = 0;
90
+ for (const line of summary.split(/\r?\n/)) {
91
+ if (!line.trim()) continue;
92
+ const matchIndex = sourceLines.indexOf(line, sourceIndex);
93
+ if (matchIndex < 0) return { valid: false, reason: 'summary-not-extractive', inputTokens, outputTokens };
94
+ sourceIndex = matchIndex + 1;
95
+ }
85
96
  return { valid: true, inputTokens, outputTokens };
86
97
  }
87
98