cabloy 5.1.213 → 5.1.215
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/.cabloy-version +1 -1
- package/.github/workflows/agent-governance.yml +19 -0
- package/CHANGELOG.md +14 -0
- package/package.json +3 -1
- package/repo-agent-governance/managed-assets.json +18 -18
- package/repo-agent-governance/policies/repository.md +3 -0
- package/repo-agent-governance/scripts/pack-check.mjs +1 -0
- package/repo-agent-governance/skills/cabloy-spec-execution/SKILL.md +7 -1
- package/repo-agent-governance/skills/cabloy-spec-execution/references/execution-protocol.md +4 -2
- package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +28 -1
- package/repo-agent-governance/skills/cabloy-spec-generation/SKILL.md +10 -0
- package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +2 -0
- package/repo-agent-governance/tests/evidence-paths.test.mjs +736 -0
- package/repo-agent-governance/tests/governance.test.mjs +25 -0
- package/repo-agent-governance/tools/evidence-paths/sanitize.mjs +549 -0
- package/repo-docs/ai/ai-spec-driven-development.md +12 -0
- package/repo-docs/ai/playbook-spec-execution.md +20 -2
- package/repo-docs/backend/migration-and-changes.md +5 -1
- package/repo-docs/backend/startup-guide.md +16 -1
- package/repo-docs/reference/repo-scripts.md +69 -1
- package/scripts/bootstrapAgentGovernance.mjs +2 -0
- package/scripts/upgrade.ts +2 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +139 -29
- package/vona/src/suite-vendor/a-vona/modules/a-printtip/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-printtip/src/bean/startup.printTip.ts +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-printtip/test/printTip.test.ts +46 -0
- package/vona/src/suite-vendor/a-vona/modules/a-startup/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-startup/src/service/startup.ts +16 -21
- package/vona/src/suite-vendor/a-vona/modules/a-startup/src/types/startup.ts +0 -1
- package/vona/src/suite-vendor/a-vona/modules/a-startup/test/devSeed.test.ts +282 -30
- package/vona/src/suite-vendor/a-vona/modules/a-version/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-version/src/service/version.ts +3 -0
- package/vona/src/suite-vendor/a-vona/modules/a-version/test/instanceSeed.test.ts +47 -0
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- package/zova/pnpm-lock.yaml +8 -8
|
@@ -147,6 +147,23 @@ test('governance adoption creates state and is idempotent', () => {
|
|
|
147
147
|
});
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
test('evidence commands are distributed through both governance reconciliation paths', () => {
|
|
151
|
+
const packageJson = JSON.parse(readFileSync(resolve(ROOT_DIR, 'package.json'), 'utf8'));
|
|
152
|
+
const bootstrap = readFileSync(resolve(ROOT_DIR, 'scripts/bootstrapAgentGovernance.mjs'), 'utf8');
|
|
153
|
+
const upgrade = readFileSync(resolve(ROOT_DIR, 'scripts/upgrade.ts'), 'utf8');
|
|
154
|
+
const bootstrapList = bootstrap.match(/const GOVERNANCE_SCRIPT_NAMES = \[([\s\S]*?)\];/)[1];
|
|
155
|
+
const upgradeList = upgrade.match(/const governanceScripts = \[([\s\S]*?)\];/)[1];
|
|
156
|
+
for (const mode of ['sanitize', 'check']) {
|
|
157
|
+
const name = `evidence:${mode}`;
|
|
158
|
+
assert.equal(
|
|
159
|
+
packageJson.scripts[name],
|
|
160
|
+
`node repo-agent-governance/tools/evidence-paths/sanitize.mjs ${mode}`,
|
|
161
|
+
);
|
|
162
|
+
assert.ok(bootstrapList.includes(`'${name}'`));
|
|
163
|
+
assert.ok(upgradeList.includes(`'${name}'`));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
150
167
|
test('initializer adopts canonical adapters for a fresh transition package', () => {
|
|
151
168
|
withFixture(root => {
|
|
152
169
|
const scriptsDirectory = resolve(root, 'scripts');
|
|
@@ -179,6 +196,8 @@ test('initializer preserves a legacy-customized adapter during a staged upgrade'
|
|
|
179
196
|
writeFileSync(resolve(root, 'CLAUDE.md'), 'legacy project customization\n');
|
|
180
197
|
const projectPackage = JSON.parse(readFileSync(resolve(ROOT_DIR, 'package.json'), 'utf8'));
|
|
181
198
|
delete projectPackage.scripts['spec:check'];
|
|
199
|
+
delete projectPackage.scripts['evidence:sanitize'];
|
|
200
|
+
projectPackage.scripts['evidence:check'] = 'node obsolete-evidence-check.mjs';
|
|
182
201
|
projectPackage.scripts['project:custom'] = 'node project-owned.mjs';
|
|
183
202
|
writeFileSync(resolve(root, 'package.json'), JSON.stringify(projectPackage));
|
|
184
203
|
|
|
@@ -207,6 +226,12 @@ test('initializer preserves a legacy-customized adapter during a staged upgrade'
|
|
|
207
226
|
updatedPackage.scripts['spec:check'],
|
|
208
227
|
'node ./repo-agent-governance/tools/spec-audit/audit.mjs',
|
|
209
228
|
);
|
|
229
|
+
for (const mode of ['sanitize', 'check']) {
|
|
230
|
+
assert.equal(
|
|
231
|
+
updatedPackage.scripts[`evidence:${mode}`],
|
|
232
|
+
`node repo-agent-governance/tools/evidence-paths/sanitize.mjs ${mode}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
210
235
|
assert.equal(updatedPackage.scripts['project:custom'], 'node project-owned.mjs');
|
|
211
236
|
});
|
|
212
237
|
});
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { constants, realpathSync } from 'node:fs';
|
|
2
|
+
import { lstat, open, readdir } from 'node:fs/promises';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join, parse, resolve, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_REPO_ROOT = resolve(fileURLToPath(import.meta.url), '../../../..');
|
|
8
|
+
export const LINE_LIMIT = 1024 * 1024;
|
|
9
|
+
export const FILE_LIMIT = 8 * 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
class SafeError extends Error {
|
|
12
|
+
constructor(code) {
|
|
13
|
+
super(code);
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fail(code) {
|
|
19
|
+
throw new SafeError(code);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function rootInfo(value) {
|
|
23
|
+
if (
|
|
24
|
+
typeof value !== 'string' ||
|
|
25
|
+
Array.from(value).some(char => char.codePointAt(0) < 32 || char.codePointAt(0) === 127)
|
|
26
|
+
) {
|
|
27
|
+
fail('INVALID_ROOT');
|
|
28
|
+
}
|
|
29
|
+
const windows = /^[A-Za-z]:[/\\]/u.test(value) || /^\\\\[^\\]+\\[^\\]+/u.test(value);
|
|
30
|
+
if (!windows && !value.startsWith('/')) fail('INVALID_ROOT');
|
|
31
|
+
let root = windows ? value.replaceAll('\\', '/') : value;
|
|
32
|
+
root = root.replace(/\/+$/u, '');
|
|
33
|
+
// Volume roots would turn a bounded recognizer into a universal slash matcher.
|
|
34
|
+
if (
|
|
35
|
+
!root ||
|
|
36
|
+
/^[A-Za-z]:$/u.test(root) ||
|
|
37
|
+
root.split('/').some(part => part === '.' || part === '..')
|
|
38
|
+
) {
|
|
39
|
+
fail('INVALID_ROOT');
|
|
40
|
+
}
|
|
41
|
+
return { root, windows, key: windows ? root.toLowerCase() : root };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function escapeRegex(value) {
|
|
45
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function rootPattern(info) {
|
|
49
|
+
const pieces = info.root.split('/').map(escapeRegex);
|
|
50
|
+
// A separator may be native, forward-slash, or a JSON-escaped backslash.
|
|
51
|
+
return pieces.join(info.windows ? '(?:\\\\{1,2}|/)' : '/');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function nativeRoots() {
|
|
55
|
+
const roots = [];
|
|
56
|
+
for (const value of [homedir(), tmpdir()]) {
|
|
57
|
+
if (!value) continue;
|
|
58
|
+
roots.push(value);
|
|
59
|
+
try {
|
|
60
|
+
roots.push(realpathSync(value));
|
|
61
|
+
} catch {
|
|
62
|
+
// Lexical coverage remains available when a native root does not exist.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return roots;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pathEnd(text, start, quoted) {
|
|
69
|
+
let end = start;
|
|
70
|
+
while (end < text.length) {
|
|
71
|
+
const char = text[end];
|
|
72
|
+
if (/[\r\n\t"'`<>(){}[\]|,;!?]/u.test(char) || (!quoted && /\s/u.test(char))) break;
|
|
73
|
+
// Preserve stack line/column suffixes (and keep other colon-delimited prose out).
|
|
74
|
+
if (char === ':') break;
|
|
75
|
+
end++;
|
|
76
|
+
}
|
|
77
|
+
// Sentence punctuation is not part of an unquoted path token.
|
|
78
|
+
if (!quoted) {
|
|
79
|
+
while (end > start && text[end - 1] === '.') end--;
|
|
80
|
+
}
|
|
81
|
+
return end;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function boundary(text, index) {
|
|
85
|
+
return index === 0 || !/[\p{L}\p{N}_./\\%~@+\->]/u.test(text[index - 1]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeTail(value, windows) {
|
|
89
|
+
return windows ? value.replace(/\\{1,2}/gu, '/') : value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Shared, bounded recognition. No universal absolute-path detection is attempted. */
|
|
93
|
+
export function createMatcher({ repoRoot = DEFAULT_REPO_ROOT, maps = [] } = {}) {
|
|
94
|
+
const explicit = [];
|
|
95
|
+
const seen = new Map();
|
|
96
|
+
function add(value, alias, privateRoot = false) {
|
|
97
|
+
const info = rootInfo(value);
|
|
98
|
+
const prior = seen.get(info.key);
|
|
99
|
+
if (prior) {
|
|
100
|
+
if (!privateRoot && prior.alias !== alias) fail('CONFLICTING_ROOT');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const entry = { ...info, alias, privateRoot };
|
|
104
|
+
entry.regex = new RegExp(rootPattern(info), info.windows ? 'iy' : 'y');
|
|
105
|
+
explicit.push(entry);
|
|
106
|
+
seen.set(info.key, entry);
|
|
107
|
+
}
|
|
108
|
+
add(repoRoot, null);
|
|
109
|
+
if (!Array.isArray(maps)) fail('INVALID_MAP');
|
|
110
|
+
for (const map of maps) {
|
|
111
|
+
if (!map || typeof map.alias !== 'string') fail('INVALID_MAP');
|
|
112
|
+
const alias = map.alias.replace(/^<([A-Z][A-Z0-9_]*)>$/u, '$1');
|
|
113
|
+
if (!/^[A-Z][A-Z0-9_]*$/u.test(alias)) fail('INVALID_ALIAS');
|
|
114
|
+
add(map.root, alias);
|
|
115
|
+
}
|
|
116
|
+
for (const root of nativeRoots()) add(root, 'PRIVATE_PATH', true);
|
|
117
|
+
explicit.sort(
|
|
118
|
+
(a, b) => b.root.length - a.root.length || Number(a.privateRoot) - Number(b.privateRoot),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const sep = '(?:\\\\{1,2}|/)';
|
|
122
|
+
const account = '[^/\\\\\\s"\'`<>(){}[\\]|,;:!?]+(?: [^/\\\\\\s"\'`<>(){}[\\]|,;:!?]+)*';
|
|
123
|
+
const known = [
|
|
124
|
+
new RegExp(`/(?:Users|home)/${account}`, 'y'),
|
|
125
|
+
/\/(?:private\/)?tmp/y,
|
|
126
|
+
/\/(?:private\/)?var\/folders/y,
|
|
127
|
+
new RegExp(`[A-Z]:${sep}(?:Users${sep}${account}|(?:Windows${sep})?Temp)`, 'iy'),
|
|
128
|
+
// UNC is a filesystem convention; forward-slash protocol-relative URLs are not.
|
|
129
|
+
/\\{2,4}[^\\/\s"'`<>(){}[\]|,;:!?]+\\{1,2}[^\\/\s"'`<>(){}[\]|,;:!?]+/y,
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
function recognizeAt(text, start, quoted = false) {
|
|
133
|
+
for (const entry of explicit) {
|
|
134
|
+
entry.regex.lastIndex = start;
|
|
135
|
+
const match = entry.regex.exec(text);
|
|
136
|
+
if (!match) continue;
|
|
137
|
+
const after = start + match[0].length;
|
|
138
|
+
if (after < text.length && !/[/\\\s"'`<>(){}[\]|,;:!?]/u.test(text[after])) continue;
|
|
139
|
+
const end = pathEnd(text, after, quoted);
|
|
140
|
+
const tail = normalizeTail(text.slice(after, end), entry.windows).replace(/^\/+|\/+$/gu, '');
|
|
141
|
+
const replacement =
|
|
142
|
+
entry.privateRoot || tail.split('/').some(part => part === '.' || part === '..')
|
|
143
|
+
? '<PRIVATE_PATH>'
|
|
144
|
+
: entry.alias
|
|
145
|
+
? `<${entry.alias}>${tail ? `/${sanitize(tail)}` : ''}`
|
|
146
|
+
: tail
|
|
147
|
+
? sanitize(tail)
|
|
148
|
+
: '.';
|
|
149
|
+
return { end, replacement };
|
|
150
|
+
}
|
|
151
|
+
for (const regex of known) {
|
|
152
|
+
regex.lastIndex = start;
|
|
153
|
+
const match = regex.exec(text);
|
|
154
|
+
if (!match) continue;
|
|
155
|
+
const after = start + match[0].length;
|
|
156
|
+
if (after < text.length && !/[/\\\s"'`<>(){}[\]|,;:!?]/u.test(text[after])) continue;
|
|
157
|
+
return { end: pathEnd(text, after, quoted), replacement: '<PRIVATE_PATH>' };
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function find(text) {
|
|
163
|
+
if (typeof text !== 'string') fail('INVALID_TEXT');
|
|
164
|
+
const matches = [];
|
|
165
|
+
// Skip non-file URLs as units, including their paths, query strings and fragments.
|
|
166
|
+
const urls = /[A-Za-z][A-Za-z0-9+.-]{0,31}:\/\/[^\s"'`<>(){}[\]|]+/gu;
|
|
167
|
+
const protectedUrls = [...text.matchAll(urls)];
|
|
168
|
+
let urlIndex = 0;
|
|
169
|
+
for (let index = 0; index < text.length; index++) {
|
|
170
|
+
const url = protectedUrls[urlIndex];
|
|
171
|
+
if (url && index === url.index) {
|
|
172
|
+
urlIndex++;
|
|
173
|
+
if (/^file:\/\//iu.test(url[0]) && boundary(text, index)) {
|
|
174
|
+
try {
|
|
175
|
+
const parsed = new URL(url[0]);
|
|
176
|
+
let local = decodeURIComponent(parsed.pathname);
|
|
177
|
+
if (parsed.hostname && parsed.hostname !== 'localhost') {
|
|
178
|
+
local = `\\\\${parsed.hostname}${local.replaceAll('/', '\\')}`;
|
|
179
|
+
} else if (/^\/[A-Za-z]:\//u.test(local)) {
|
|
180
|
+
local = local.slice(1);
|
|
181
|
+
}
|
|
182
|
+
const found = recognizeAt(local, 0, true);
|
|
183
|
+
matches.push({
|
|
184
|
+
start: index,
|
|
185
|
+
end: index + url[0].length,
|
|
186
|
+
replacement: found ? found.replacement + local.slice(found.end) : '<PRIVATE_PATH>',
|
|
187
|
+
});
|
|
188
|
+
} catch {
|
|
189
|
+
// Malformed file URLs are not safe to partially decode or echo.
|
|
190
|
+
matches.push({
|
|
191
|
+
start: index,
|
|
192
|
+
end: index + url[0].length,
|
|
193
|
+
replacement: '<PRIVATE_PATH>',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
index += url[0].length - 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (!boundary(text, index)) continue;
|
|
201
|
+
const quoted = index > 0 && /["'`]/u.test(text[index - 1]);
|
|
202
|
+
const found = recognizeAt(text, index, quoted);
|
|
203
|
+
if (found) {
|
|
204
|
+
matches.push({ start: index, ...found });
|
|
205
|
+
index = found.end - 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return matches;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sanitize(text) {
|
|
212
|
+
let result = '';
|
|
213
|
+
let offset = 0;
|
|
214
|
+
for (const match of find(text)) {
|
|
215
|
+
result += text.slice(offset, match.start) + match.replacement;
|
|
216
|
+
offset = match.end;
|
|
217
|
+
}
|
|
218
|
+
return result + text.slice(offset);
|
|
219
|
+
}
|
|
220
|
+
return Object.freeze({ find, sanitize });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function decode(bytes) {
|
|
224
|
+
try {
|
|
225
|
+
return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
226
|
+
} catch {
|
|
227
|
+
fail('INVALID_UTF8');
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function write(output, text) {
|
|
232
|
+
try {
|
|
233
|
+
await new Promise((resolveWrite, reject) => {
|
|
234
|
+
output.write(text, error => (error ? reject(error) : resolveWrite()));
|
|
235
|
+
});
|
|
236
|
+
} catch {
|
|
237
|
+
fail('OUTPUT_ERROR');
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Emits only fully validated, sanitized lines; preceding successful lines may remain on failure. */
|
|
242
|
+
export async function sanitizeStream(input, output, { matcher = createMatcher() } = {}) {
|
|
243
|
+
let outputFailed = false;
|
|
244
|
+
const onError = () => {
|
|
245
|
+
outputFailed = true;
|
|
246
|
+
};
|
|
247
|
+
output.on('error', onError);
|
|
248
|
+
let parts = [];
|
|
249
|
+
let length = 0;
|
|
250
|
+
try {
|
|
251
|
+
for await (const chunk of input) {
|
|
252
|
+
if (outputFailed) fail('OUTPUT_ERROR');
|
|
253
|
+
// Buffer input is required so upstream decoding cannot hide invalid UTF-8.
|
|
254
|
+
if (!(chunk instanceof Uint8Array)) fail('INVALID_INPUT');
|
|
255
|
+
const bytes = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
256
|
+
let start = 0;
|
|
257
|
+
while (start < bytes.length) {
|
|
258
|
+
const newline = bytes.indexOf(10, start);
|
|
259
|
+
const end = newline < 0 ? bytes.length : newline;
|
|
260
|
+
length += end - start;
|
|
261
|
+
if (length > LINE_LIMIT) fail('LINE_LIMIT');
|
|
262
|
+
parts.push(bytes.subarray(start, end));
|
|
263
|
+
if (newline < 0) break;
|
|
264
|
+
const line = Buffer.concat(parts, length);
|
|
265
|
+
if (binary(line)) fail('BINARY');
|
|
266
|
+
const text = decode(line);
|
|
267
|
+
await write(output, `${matcher.sanitize(text)}\n`);
|
|
268
|
+
parts = [];
|
|
269
|
+
length = 0;
|
|
270
|
+
start = newline + 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (length) {
|
|
274
|
+
const line = Buffer.concat(parts, length);
|
|
275
|
+
if (binary(line)) fail('BINARY');
|
|
276
|
+
await write(output, matcher.sanitize(decode(line)));
|
|
277
|
+
}
|
|
278
|
+
if (outputFailed) fail('OUTPUT_ERROR');
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (error instanceof SafeError) throw error;
|
|
281
|
+
fail('INPUT_ERROR');
|
|
282
|
+
} finally {
|
|
283
|
+
output.off('error', onError);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function safeFilename(value, matcher) {
|
|
288
|
+
return matcher
|
|
289
|
+
.sanitize(value)
|
|
290
|
+
.replace(
|
|
291
|
+
/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}:\\]/gu,
|
|
292
|
+
char => `\\u${char.codePointAt(0).toString(16).padStart(4, '0')}`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function binary(bytes) {
|
|
297
|
+
return bytes.some(
|
|
298
|
+
byte => byte === 0 || byte === 127 || (byte < 32 && ![9, 10, 12, 13].includes(byte)),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function ancestorExclusion(path) {
|
|
303
|
+
const root = parse(path).root;
|
|
304
|
+
let current = root;
|
|
305
|
+
// resolve() normalizes native separators; POSIX backslashes remain literal filename characters.
|
|
306
|
+
for (const part of path.slice(root.length).split(sep)) {
|
|
307
|
+
if (!part) continue;
|
|
308
|
+
if (part.startsWith('.env')) return 'ENV_EXCLUDED';
|
|
309
|
+
current = join(current, part);
|
|
310
|
+
try {
|
|
311
|
+
if ((await lstat(current)).isSymbolicLink()) return 'SYMLINK_EXCLUDED';
|
|
312
|
+
} catch {
|
|
313
|
+
return 'UNREADABLE';
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Read-only, explicit scopes. Incomplete takes precedence over findings. */
|
|
320
|
+
export async function checkPaths(scopes, { matcher = createMatcher() } = {}) {
|
|
321
|
+
if (
|
|
322
|
+
!Array.isArray(scopes) ||
|
|
323
|
+
!scopes.length ||
|
|
324
|
+
scopes.some(scope => typeof scope !== 'string' || !scope)
|
|
325
|
+
) {
|
|
326
|
+
fail('INVALID_SCOPE');
|
|
327
|
+
}
|
|
328
|
+
const diagnostics = [];
|
|
329
|
+
let incomplete = false;
|
|
330
|
+
let findings = false;
|
|
331
|
+
function report(label, line, code) {
|
|
332
|
+
diagnostics.push(`${label}:${line} ${code}`);
|
|
333
|
+
if (code === 'PATH_FOUND') findings = true;
|
|
334
|
+
else incomplete = true;
|
|
335
|
+
}
|
|
336
|
+
async function scan(path, label) {
|
|
337
|
+
if (basename(path).startsWith('.env')) {
|
|
338
|
+
report(label, 0, 'ENV_EXCLUDED');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
let stat;
|
|
342
|
+
try {
|
|
343
|
+
stat = await lstat(path);
|
|
344
|
+
} catch {
|
|
345
|
+
report(label, 0, 'UNREADABLE');
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (stat.isSymbolicLink()) {
|
|
349
|
+
report(label, 0, 'SYMLINK_EXCLUDED');
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (stat.isDirectory()) {
|
|
353
|
+
let entries;
|
|
354
|
+
try {
|
|
355
|
+
entries = await readdir(path);
|
|
356
|
+
} catch {
|
|
357
|
+
report(label, 0, 'UNREADABLE');
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
entries.sort();
|
|
361
|
+
for (const entry of entries)
|
|
362
|
+
await scan(join(path, entry), `${label}/${safeFilename(entry, matcher)}`);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (!stat.isFile()) {
|
|
366
|
+
report(label, 0, 'SPECIAL_EXCLUDED');
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (stat.size > FILE_LIMIT) {
|
|
370
|
+
report(label, 0, 'SIZE_LIMIT');
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// POSIX mode-000 files remain unreadable even for privileged runners; Windows relies on open/ACL errors.
|
|
374
|
+
if (process.platform !== 'win32' && (stat.mode & 0o444) === 0) {
|
|
375
|
+
report(label, 0, 'UNREADABLE');
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
let handle;
|
|
379
|
+
let bytes;
|
|
380
|
+
try {
|
|
381
|
+
handle = await open(
|
|
382
|
+
path,
|
|
383
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0),
|
|
384
|
+
);
|
|
385
|
+
const opened = await handle.stat();
|
|
386
|
+
if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev)
|
|
387
|
+
fail('UNREADABLE');
|
|
388
|
+
const chunks = [];
|
|
389
|
+
let size = 0;
|
|
390
|
+
while (true) {
|
|
391
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
392
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
|
393
|
+
if (!bytesRead) break;
|
|
394
|
+
size += bytesRead;
|
|
395
|
+
if (size > FILE_LIMIT) fail('SIZE_LIMIT');
|
|
396
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
397
|
+
}
|
|
398
|
+
bytes = Buffer.concat(chunks, size);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
report(label, 0, error instanceof SafeError ? error.code : 'UNREADABLE');
|
|
401
|
+
return;
|
|
402
|
+
} finally {
|
|
403
|
+
if (handle) await handle.close().catch(() => {});
|
|
404
|
+
}
|
|
405
|
+
if (binary(bytes)) {
|
|
406
|
+
report(label, 0, 'BINARY');
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
let text;
|
|
410
|
+
try {
|
|
411
|
+
text = decode(bytes);
|
|
412
|
+
} catch {
|
|
413
|
+
report(label, 0, 'INVALID_UTF8');
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const lines = text.split('\n');
|
|
417
|
+
if (lines.some(line => Buffer.byteLength(line) > LINE_LIMIT)) {
|
|
418
|
+
report(label, 0, 'LINE_LIMIT');
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
for (let index = 0; index < lines.length; index++) {
|
|
422
|
+
if (matcher.find(lines[index]).length) report(label, index + 1, 'PATH_FOUND');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// Input order defines stable scope IDs; each directory is visited in lexical order.
|
|
426
|
+
for (const [index, scope] of scopes.entries()) {
|
|
427
|
+
const path = resolve(scope);
|
|
428
|
+
const label = `scope-${index + 1}`;
|
|
429
|
+
const exclusion = await ancestorExclusion(path);
|
|
430
|
+
if (exclusion) {
|
|
431
|
+
report(label, 0, exclusion);
|
|
432
|
+
} else {
|
|
433
|
+
let directory = false;
|
|
434
|
+
try {
|
|
435
|
+
directory = (await lstat(path)).isDirectory();
|
|
436
|
+
} catch {
|
|
437
|
+
/* scan reports safely */
|
|
438
|
+
}
|
|
439
|
+
await scan(path, directory ? label : `${label}/${safeFilename(basename(path), matcher)}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return { status: incomplete ? 2 : findings ? 1 : 0, diagnostics };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const HELP = `Usage:
|
|
446
|
+
node sanitize.mjs sanitize [--repo-root ROOT] [--map ROOT=ALIAS ...]
|
|
447
|
+
node sanitize.mjs check [--repo-root ROOT] [--map ROOT=ALIAS ...] -- FILE_OR_DIR ...
|
|
448
|
+
|
|
449
|
+
sanitize reads raw UTF-8 stdin and writes sanitized stdout only. It never runs commands,
|
|
450
|
+
opens input files, writes files, or spools raw input. check is read-only and scans only
|
|
451
|
+
explicit scopes, recursively in sorted order, without extension filtering.
|
|
452
|
+
ROOT must be an absolute Unix, drive-qualified Windows, or backslash UNC path (not a
|
|
453
|
+
volume root). ALIAS is NAME or <NAME>, matching [A-Z][A-Z0-9_]*. Repeat --map to add
|
|
454
|
+
roots. Longest root wins; conflicting replacements for the same root are rejected.
|
|
455
|
+
The default repo root is resolved from this tool's location, not the working directory.
|
|
456
|
+
Checkout paths become repo-relative (root becomes .); maps become <ALIAS>/suffix.
|
|
457
|
+
Native os home/temp roots (lexical and realpath), /Users/name, /home/name, /tmp,
|
|
458
|
+
/private/tmp, /var/folders, /private/var/folders, Windows Users/Temp conventions and
|
|
459
|
+
backslash UNC paths become <PRIVATE_PATH> for the complete recognized path token.
|
|
460
|
+
Native/forward/JSON-escaped Windows separators and file:// URLs are supported.
|
|
461
|
+
Quote path tokens containing spaces; explicit root spaces and account-name spaces
|
|
462
|
+
are also recognized. Unquoted tokens end at whitespace or common prose delimiters.
|
|
463
|
+
Stack :line:column suffixes are preserved. Non-file URLs, unrelated API/container
|
|
464
|
+
paths and unknown absolute roots are left unchanged: this is NOT a universal secret
|
|
465
|
+
scanner. Use explicit maps for other local roots; inspect output before publication.
|
|
466
|
+
UTF-8 decoding is fatal, with a 1 MiB byte limit per line (including CR, excluding LF).
|
|
467
|
+
NUL, DEL and non-text C0 controls are rejected; tab, LF, form feed and CR are allowed.
|
|
468
|
+
Only complete validated lines are emitted; earlier sanitized lines may survive failure.
|
|
469
|
+
check limits each file to 8 MiB, treats NUL/non-text controls as binary, and reports only
|
|
470
|
+
scope-N/relative-filename:line CODE, never source lines or absolute scan roots.
|
|
471
|
+
Symlinks (including scope ancestors), special files, .env* files/directories, unreadable,
|
|
472
|
+
binary, invalid UTF-8 and over-limit files make checking incomplete. Scope trees must
|
|
473
|
+
remain stable while scanning; this is not an adversarial filesystem snapshot.
|
|
474
|
+
Exit status: sanitize 0 success / 2 error; check 0 clean / 1 findings / 2 incomplete/error.
|
|
475
|
+
Options must precede --; check requires -- and at least one explicit scope.
|
|
476
|
+
`;
|
|
477
|
+
|
|
478
|
+
export async function runCli(
|
|
479
|
+
args,
|
|
480
|
+
{ input = process.stdin, output = process.stdout, error = process.stderr } = {},
|
|
481
|
+
) {
|
|
482
|
+
let outputFailed = false;
|
|
483
|
+
const onError = () => {
|
|
484
|
+
outputFailed = true;
|
|
485
|
+
};
|
|
486
|
+
output.on('error', onError);
|
|
487
|
+
// Diagnostics must themselves tolerate a closed stderr, without a raw exception stack.
|
|
488
|
+
const ignoreError = () => {};
|
|
489
|
+
error.on('error', ignoreError);
|
|
490
|
+
try {
|
|
491
|
+
if (args.length === 1 && args[0] === '--help') {
|
|
492
|
+
await write(output, HELP);
|
|
493
|
+
return 0;
|
|
494
|
+
}
|
|
495
|
+
const [command, ...rest] = args;
|
|
496
|
+
if (!['sanitize', 'check'].includes(command)) fail('INVALID_USAGE');
|
|
497
|
+
if (rest.length === 1 && rest[0] === '--help') {
|
|
498
|
+
await write(output, HELP);
|
|
499
|
+
return 0;
|
|
500
|
+
}
|
|
501
|
+
const options = { maps: [] };
|
|
502
|
+
let scopes;
|
|
503
|
+
for (let index = 0; index < rest.length; index++) {
|
|
504
|
+
const arg = rest[index];
|
|
505
|
+
if (arg === '--' && command === 'check') {
|
|
506
|
+
scopes = rest.slice(index + 1);
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
if (!['--repo-root', '--map'].includes(arg) || index + 1 >= rest.length)
|
|
510
|
+
fail('INVALID_USAGE');
|
|
511
|
+
const value = rest[++index];
|
|
512
|
+
if (arg === '--repo-root') {
|
|
513
|
+
if (options.repoRoot !== undefined) fail('INVALID_USAGE');
|
|
514
|
+
options.repoRoot = value;
|
|
515
|
+
} else {
|
|
516
|
+
const equal = value.lastIndexOf('=');
|
|
517
|
+
if (equal <= 0) fail('INVALID_MAP');
|
|
518
|
+
options.maps.push({ root: value.slice(0, equal), alias: value.slice(equal + 1) });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const matcher = createMatcher(options);
|
|
522
|
+
if (command === 'sanitize') {
|
|
523
|
+
await sanitizeStream(input, output, { matcher });
|
|
524
|
+
return 0;
|
|
525
|
+
}
|
|
526
|
+
if (!scopes?.length) fail('INVALID_SCOPE');
|
|
527
|
+
const result = await checkPaths(scopes, { matcher });
|
|
528
|
+
for (const diagnostic of result.diagnostics) await write(output, `${diagnostic}\n`);
|
|
529
|
+
if (outputFailed) fail('OUTPUT_ERROR');
|
|
530
|
+
return result.status;
|
|
531
|
+
} catch (caught) {
|
|
532
|
+
const code = caught instanceof SafeError ? caught.code : 'INTERNAL_ERROR';
|
|
533
|
+
try {
|
|
534
|
+
await write(error, `evidence-paths: ${code}\n`);
|
|
535
|
+
} catch {
|
|
536
|
+
/* no unsafe fallback */
|
|
537
|
+
}
|
|
538
|
+
return 2;
|
|
539
|
+
} finally {
|
|
540
|
+
output.off('error', onError);
|
|
541
|
+
error.off('error', ignoreError);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
546
|
+
runCli(process.argv.slice(2)).then(status => {
|
|
547
|
+
process.exitCode = status;
|
|
548
|
+
});
|
|
549
|
+
}
|
|
@@ -73,6 +73,18 @@ Before source work begins, an **execution dossier** makes the unit explicit: tar
|
|
|
73
73
|
|
|
74
74
|
This structure lets a team pause, review, or transfer work without treating an unfinished implementation as verified. See [Execute an Approved Cabloy Specification Increment](/ai/playbook-spec-execution) for the readiness gates, routing rules, evidence protocol, and handoff format.
|
|
75
75
|
|
|
76
|
+
## Converge planning into implementation
|
|
77
|
+
|
|
78
|
+
Planning should resolve the material blockers of the next bounded increment, not specify every future detail before any code is written. Keep a finite blocker/exit checklist in the existing owning records: the unresolved decision, why it blocks this increment, its decision owner, and the condition that closes it. Separate deferrable details and later increments; batch related decisions instead of turning each clarification into another preparation and review cycle.
|
|
79
|
+
|
|
80
|
+
Before adding a planning pass, explain which blocker or material uncertainty it addresses, what new fact or decision value it should provide, and how it ends. If the next pass only restates an accepted direction without a justified new approach to the remaining blockers, report stalled convergence and recommend a concrete decision, bounded feasibility check, or scope disposition. A larger document or another review is not delivery progress by itself.
|
|
81
|
+
|
|
82
|
+
Blockers follow actual dependencies, not the whole suite by default. A blocked identity integration does not necessarily block an independent capability whose own contracts, dependencies, safety boundary, and verification plan are ready. Conversely, a frontend change that depends on an unresolved response contract cannot bypass that dependency by calling itself an independent increment. Surface a meaningful, independently verifiable candidate for approval when available; if none exists, state why. Foundation-only work is useful only when it has explicit integration value and checks, not merely an empty scaffold presented as feature completion.
|
|
83
|
+
|
|
84
|
+
Planning is sufficient for an increment when its material contracts, dependencies, scope, safety constraints, and verification plan are settled. At that point, request any missing execution approval or proceed within valid existing approval. Reuse an unchanged approved dossier after checking current revision, worktree attribution, and evidence applicability; a skill handoff, resumed session, or ordinary implementation substep is not a reason to request the same permission again. Material changes, newly observed risks, and the approval's own conditions can require renewed approval; prior approval never covers unrelated tasks or unapproved external/destructive operations.
|
|
85
|
+
|
|
86
|
+
This is not a fixed limit on planning rounds or a relaxation of security requirements. Preserve mandatory reviews, predecessor proof, and any specifically required pre-implementation qualification. The new implementation's runtime ATP proof normally follows implementation; it must not become an impossible prerequisite to writing that implementation. Evidence and release gates still control later verification and release claims. A genuine new risk can reopen planning, but the return should name the affected blocker and its exit condition rather than restart open-ended refinement.
|
|
87
|
+
|
|
76
88
|
## Contract Loop is complementary
|
|
77
89
|
|
|
78
90
|
[Contract Loop](/fullstack/contract-loop-playbook) is Cabloy's bidirectional Vona↔Zova synchronization model. It determines where fullstack contract truth lives, which generated handoff must be refreshed, and how to recover from forward-chain, reverse-chain, consumer, or local-dependency drift.
|
|
@@ -102,11 +102,29 @@ The execution workflow supplies the approved dossier and preserves the boundary;
|
|
|
102
102
|
Use the narrowest approved check first, then follow the linked ATP procedures and applicable release gates.
|
|
103
103
|
|
|
104
104
|
1. run the scoped verification specified by the increment
|
|
105
|
-
2.
|
|
106
|
-
3.
|
|
105
|
+
2. sanitize machine-local paths and review secrets, credentials, raw tokens, customer data, and provider identifiers **before** durable retention or sharing
|
|
106
|
+
3. retain only reviewed, redacted observed evidence using the suite's existing convention
|
|
107
107
|
4. record failures, waivers, invalidation, and supersession rather than erasing history
|
|
108
108
|
5. update evidence before updating derived progress
|
|
109
109
|
|
|
110
|
+
For path-bearing output, combine producer stdout/stderr before sanitizing; do not save a raw intermediate log or use `tee` ahead of the sanitizer. For example, in Bash, using a new output filename (do not enable `set -e` before capturing a known failing verification result):
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
set -o pipefail
|
|
114
|
+
set -o noclobber
|
|
115
|
+
npm run --silent test:agent-governance 2>&1 | npm run --silent evidence:sanitize > evidence.pending.txt
|
|
116
|
+
stage_status=( "${PIPESTATUS[@]}" )
|
|
117
|
+
printf 'producer_exit=%s sanitizer_exit=%s\n' "${stage_status[0]}" "${stage_status[1]}"
|
|
118
|
+
# Only check/retain the artifact if sanitizer_exit is 0; preserve producer failure.
|
|
119
|
+
if [ "${stage_status[1]}" -eq 0 ]; then
|
|
120
|
+
npm run --silent evidence:check -- -- evidence.pending.txt
|
|
121
|
+
fi
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Choose the producer from the approved verification scope; this example does not authorize unrelated tests. Evaluate both producer and sanitizer statuses before accepting the artifact. `pipefail` catches upstream failure, but exact per-stage exit codes require immediately capturing Bash `PIPESTATUS` or Zsh `pipestatus` before another command. A failed sanitizer may leave partial sanitized output; do not accept it as evidence. A clean path check cannot turn a failed test into a pass. This pipeline is Bash-specific; Windows/PowerShell requires deliberate UTF-8 handling and separate native process statuses. See [Windows and PowerShell](/reference/repo-scripts#windows-and-powershell) rather than reusing Bash/Zsh syntax.
|
|
125
|
+
|
|
126
|
+
The sanitizer preserves checkout-relative locations and uses aliases for external roots. It does not automatically intercept terminal/tool output, recognize every absolute path, remove secrets, or certify safe publication. Review remaining sensitive data separately. Preserve original failures, diagnostic meaning and source hashes; label sanitized historical diffs as path-redacted, not byte-exact/apply-ready. See [Evidence path sanitization](/reference/repo-scripts#evidence-path-sanitization) for mappings, supported coverage and incomplete-scan behavior.
|
|
127
|
+
|
|
110
128
|
Use status precisely:
|
|
111
129
|
|
|
112
130
|
- `in-progress` — approved work or verification has actually begun
|
|
@@ -173,6 +173,8 @@ This is valuable because shared baseline data becomes part of the structured ver
|
|
|
173
173
|
|
|
174
174
|
Vona invokes `seed()` while the application starts in test mode (`npm run test` or managed E2E), and during an explicit `npm run db:reset` in dev mode. In **Cabloy Basic and Cabloy Start**, ordinary `npm run dev` and `npm run dev:one` only reuse existing managed disposable databases and never seed; a missing required database makes startup fail with an instruction to run `npm run db:reset`. Framework upgrades do not reset existing development data or repeat its seeds. Use `seed()` for durable baseline fixtures shared by tests or for freshly prepared local development data. Seeding follows instance initialization on a newly created or recreated disposable database; `seed()` is not a repeatable, same-database data-import mechanism. See [Managed development database preparation](#managed-development-database-preparation) for the explicit reset lifecycle.
|
|
175
175
|
|
|
176
|
+
In **Cabloy Basic and Cabloy Start**, automatic `seed()` execution is limited to the default instance (`''`). These records are convenience fixtures for test/dev use, not required business initialization data. Reset still migrates and initializes configured isolated targets such as `isolateTest`, but does not seed them: they can exercise multi-instance behavior without shared test fixtures. Use `init()` for initialization required by each instance.
|
|
177
|
+
|
|
176
178
|
Treat shared `seed()` records as read-only in ordinary tests. A scenario that needs different state must create independent, test-owned data and clean it up in `finally`; it must not mutate or delete the shared baseline. `seed()` remains distinct from `init()`: use `init()` for instance-aware initialization data and reserve `seed()` for baseline data in test runs or explicit development resets.
|
|
177
179
|
|
|
178
180
|
## Version changes across the generated backend thread
|
|
@@ -217,7 +219,9 @@ When `database.testDatabase: true` enables framework-managed disposable dev data
|
|
|
217
219
|
1. **Check every required target:** both shared and statically configured isolate database targets must exist. If any required target is missing, startup fails promptly with an instruction to run `npm run db:reset`; it does not create the target, perform creation-time Redis cleanup, or seed as a fallback.
|
|
218
220
|
2. **Reuse existing databases:** keep their data without database creation, creation-time Redis cleanup, or module seeding. Ordinary startup is not a fixture refresh, an implicit reset, or recovery for a partial reset. Non-disposable databases are outside this managed reset path.
|
|
219
221
|
|
|
220
|
-
An explicit `npm run db:reset` owns preparation in a single process. It requires `database.testDatabase: true` and a connection outside the disposable database. It clears validated application-owned Redis state with `clearAllData()`, drops and recreates the managed disposable dev databases, runs migrations
|
|
222
|
+
An explicit `npm run db:reset` owns preparation in a single process. It requires `database.testDatabase: true` and a connection outside the disposable database. It clears validated application-owned Redis state with `clearAllData()`, drops and recreates the managed disposable dev databases, and runs migrations. It then completes each instance's non-after startups and initialization in sequence. Only the default instance runs the owning modules' `seed()` hooks, after its initialization and before consuming its creation bookkeeping and setting its readiness flag; isolated instances initialize and become ready without seeding.
|
|
223
|
+
|
|
224
|
+
Reset skips all instance and application startups with `after: true`: schedule loading, runtime JSON generation, development tips, queue-worker preloading, and HTTP listening are runtime-server work, not reset prerequisites. Put required data preparation in migrations, `init()`, `seed()`, or non-after startups. Normal dev startup still runs after-startups; independent framework lifecycle hooks and reset's application teardown remain intact. See [Backend Startup Guide](/backend/startup-guide#app-startup-and-instance-startup-in-practice) for the phase boundary.
|
|
221
225
|
|
|
222
226
|
**Stop the development server and any other processes using the same database and Redis namespace first.** There is no file-based preparation lock; concurrent dev/dev and dev/reset against the same database and Redis namespace are unsupported. The reset command disables its own listener, but that does not protect a concurrently running server or queue worker. Never reset development data you need to keep.
|
|
223
227
|
|