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 +1 -1
- package/src/artifact-lifecycle.mjs +17 -0
- package/src/history-archive.mjs +4 -2
- package/src/hook-cli.mjs +3 -3
- package/src/redaction-profile.mjs +61 -13
- package/src/semantic-compactor.mjs +12 -1
package/package.json
CHANGED
|
@@ -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;
|
package/src/history-archive.mjs
CHANGED
|
@@ -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'
|
|
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'
|
|
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
|
-
|
|
83
|
-
assignments.push(
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
'
|
|
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
|
|