yam-flow 2.7.0 → 2.8.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/AGENTS.md +5 -0
- package/CHANGELOG.md +41 -0
- package/COMMANDS.md +52 -5
- package/README.md +40 -8
- package/ROADMAP.md +17 -6
- package/dist/bin/yam.js +462 -74
- package/dist/lib/external-updates.d.ts +9 -0
- package/dist/lib/external-updates.js +232 -4
- package/dist/lib/next-step.d.ts +61 -0
- package/dist/lib/next-step.js +340 -0
- package/dist/lib/release-auth-readiness.d.ts +81 -0
- package/dist/lib/release-auth-readiness.js +106 -0
- package/dist/lib/release-registry-status.d.ts +18 -0
- package/dist/lib/release-registry-status.js +37 -0
- package/dist/lib/scout-receipt.d.ts +89 -0
- package/dist/lib/scout-receipt.js +542 -0
- package/dist/lib/security-check.d.ts +98 -0
- package/dist/lib/security-check.js +262 -0
- package/dist/lib/security-integration.d.ts +8 -0
- package/dist/lib/security-integration.js +428 -0
- package/dist/lib/skill-installation.d.ts +30 -0
- package/dist/lib/skill-installation.js +470 -82
- package/dist/lib/trust-kernel.d.ts +2 -0
- package/dist/lib/trust-kernel.js +21 -2
- package/package.json +26 -4
- package/references/final-report.md +17 -1
- package/references/next-step.md +67 -0
- package/references/scout-wide-scan.md +16 -0
- package/references/security-check.md +131 -0
- package/references/study-note.md +10 -2
- package/references/tool-trust-layer.md +2 -0
- package/skills/deep/SKILL.md +6 -0
- package/skills/mission/SKILL.md +6 -0
- package/skills/quick/SKILL.md +5 -0
- package/skills/scout/SKILL.md +19 -9
- package/skills/ueye/SKILL.md +5 -0
- package/src/bin/yam.ts +465 -72
- package/src/lib/external-updates.ts +262 -4
- package/src/lib/next-step.ts +397 -0
- package/src/lib/release-auth-readiness.ts +148 -0
- package/src/lib/release-registry-status.ts +55 -0
- package/src/lib/scout-receipt.ts +593 -0
- package/src/lib/security-check.ts +184 -0
- package/src/lib/security-integration.ts +299 -0
- package/src/lib/skill-installation.ts +542 -87
- package/src/lib/trust-kernel.ts +25 -2
- package/templates/security-check-note.md +42 -0
- package/templates/security-gate.yml +44 -0
- package/yam.manifest.json +4 -3
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import { constants, readFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { createSecurityStarter, validateSecurityCheck, persistSecurityCheck, SECURITY_AREA_IDS } from './security-check.js';
|
|
8
|
+
const OID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
9
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
10
|
+
const ZERO = /^0+$/;
|
|
11
|
+
const hash = (text) => `sha256:${createHash('sha256').update(text).digest('hex')}`;
|
|
12
|
+
function git(cwd, args) {
|
|
13
|
+
try {
|
|
14
|
+
return execFileSync('git', ['-C', cwd, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 8 * 1024 * 1024 }).trim();
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error('git binding inspection failed');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function revision(cwd, ref) {
|
|
21
|
+
if (!OID.test(ref))
|
|
22
|
+
throw new Error('revision must be an exact Git object ID');
|
|
23
|
+
const result = git(cwd, ['rev-parse', '--verify', `${ref}^{commit}`]);
|
|
24
|
+
if (!OID.test(result))
|
|
25
|
+
throw new Error('invalid source commit');
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
function parseArgs(args) {
|
|
29
|
+
const out = new Map();
|
|
30
|
+
for (let i = 0; i < args.length; i++) {
|
|
31
|
+
if (args[i] === '--json')
|
|
32
|
+
continue;
|
|
33
|
+
if (!['--policy', '--policy-digest', '--evidence', '--note', '--stage', '--remote', '--destination', '--source', '--base', '--artifact', '--artifact-digest', '--configuration', '--configuration-digest', '--environment', '--cli'].includes(args[i]) || !args[i + 1] || args[i + 1].startsWith('--') || out.has(args[i]))
|
|
34
|
+
throw new Error('invalid or duplicate security option');
|
|
35
|
+
out.set(args[i], args[++i]);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
export function collectSecurityBinding(cwd, stage, options, stdin = '') {
|
|
40
|
+
if (stage === 'commit') {
|
|
41
|
+
let base;
|
|
42
|
+
try {
|
|
43
|
+
base = git(cwd, ['rev-parse', '--verify', 'HEAD']);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
base = execFileSync('git', ['-C', cwd, 'hash-object', '-t', 'tree', '--stdin'], { input: '', encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
47
|
+
}
|
|
48
|
+
return { stage, staged_tree: git(cwd, ['write-tree']), base_revision: base };
|
|
49
|
+
}
|
|
50
|
+
if (stage === 'push') {
|
|
51
|
+
const remote = options.get('--remote');
|
|
52
|
+
if (!remote || /[\r\n\0]/.test(remote))
|
|
53
|
+
throw new Error('push requires remote identity');
|
|
54
|
+
const destination = options.get('--destination');
|
|
55
|
+
if (!destination || /[\r\n\0]/.test(destination))
|
|
56
|
+
throw new Error('push requires actual Git hook destination');
|
|
57
|
+
const lines = stdin.trim().split('\n').filter(Boolean);
|
|
58
|
+
if (!lines.length || lines.length > 256)
|
|
59
|
+
throw new Error('push requires exact outgoing refs from hook stdin');
|
|
60
|
+
const push_refs = lines.map(line => {
|
|
61
|
+
const fields = line.trim().split(/\s+/);
|
|
62
|
+
if (fields.length !== 4 || !OID.test(fields[1]) || !OID.test(fields[3]) || ZERO.test(fields[1]) || !(fields[0] === 'HEAD' || fields[0].startsWith('refs/')) || !fields[2].startsWith('refs/'))
|
|
63
|
+
throw new Error('invalid push ref or deletion; deletion requires separate reviewed handling');
|
|
64
|
+
revision(cwd, fields[1]);
|
|
65
|
+
return { local_ref: fields[0], local_oid: fields[1], remote_ref: fields[2], remote_oid: fields[3] };
|
|
66
|
+
});
|
|
67
|
+
if (new Set(push_refs.map(r => r.remote_ref)).size !== push_refs.length)
|
|
68
|
+
throw new Error('duplicate outgoing destination ref');
|
|
69
|
+
return { stage, destination_digest: hash(destination), push_refs };
|
|
70
|
+
}
|
|
71
|
+
if (stage === 'ci') {
|
|
72
|
+
const source = revision(cwd, options.get('--source') || '');
|
|
73
|
+
const base = revision(cwd, options.get('--base') || '');
|
|
74
|
+
return { stage: 'push', context: 'ci', source_revision: source, base_revision: base, tree: git(cwd, ['rev-parse', `${source}^{tree}`]) };
|
|
75
|
+
}
|
|
76
|
+
if (stage === 'deploy') {
|
|
77
|
+
const source = revision(cwd, options.get('--source') || '');
|
|
78
|
+
const artifact = options.get('--artifact-digest') || '';
|
|
79
|
+
const configuration = options.get('--configuration-digest') || '';
|
|
80
|
+
const environment = options.get('--environment') || '';
|
|
81
|
+
if (!DIGEST.test(artifact) || !DIGEST.test(configuration) || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(environment))
|
|
82
|
+
throw new Error('deploy requires exact artifact/configuration SHA256 and environment');
|
|
83
|
+
return { stage, source_revision: source, artifact_digest: artifact, configuration_digest: configuration, environment };
|
|
84
|
+
}
|
|
85
|
+
throw new Error('stage must be commit, push, ci or deploy');
|
|
86
|
+
}
|
|
87
|
+
async function readSafe(file) {
|
|
88
|
+
await safeParents(file);
|
|
89
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
90
|
+
try {
|
|
91
|
+
const info = await handle.stat();
|
|
92
|
+
if (!info.isFile() || info.size > 1024 * 1024)
|
|
93
|
+
throw new Error('invalid security file');
|
|
94
|
+
return await handle.readFile('utf8');
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
await handle.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function quote(value) { return `'${value.replace(/'/g, `'\\''`)}'`; }
|
|
101
|
+
async function exclusive(file, text, mode = 0o600) {
|
|
102
|
+
await safeParents(file);
|
|
103
|
+
const handle = await fs.open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode);
|
|
104
|
+
try {
|
|
105
|
+
await handle.writeFile(text, 'utf8');
|
|
106
|
+
await handle.sync();
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await handle.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function gitMetadata(cwd) {
|
|
113
|
+
const root = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
114
|
+
const directory = path.resolve(cwd, git(cwd, ['rev-parse', '--git-dir']));
|
|
115
|
+
const hookOverride = (() => { try {
|
|
116
|
+
return git(cwd, ['config', '--get', 'core.hooksPath']);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return '';
|
|
120
|
+
} })();
|
|
121
|
+
if (hookOverride)
|
|
122
|
+
throw new Error('custom hooksPath requires explicit manual integration');
|
|
123
|
+
return { root, directory, hooks: path.join(directory, 'hooks'), config: path.join(directory, 'yam-security.json') };
|
|
124
|
+
}
|
|
125
|
+
async function enableHooks(cwd, options, cliPath) {
|
|
126
|
+
if (process.platform === 'win32')
|
|
127
|
+
throw new Error('automatic hook setup requires a POSIX Git shell; integrate manually on Windows');
|
|
128
|
+
const metadata = await gitMetadata(cwd);
|
|
129
|
+
const settings = JSON.parse(await readSafe(metadata.config));
|
|
130
|
+
if (settings.schema !== 'yam.security-install.v1' || !DIGEST.test(settings.policy_digest))
|
|
131
|
+
throw new Error('security init required before hooks enable');
|
|
132
|
+
const cli = await fs.realpath(cliPath || options.get('--cli') || fileURLToPath(new URL('../bin/yam.js', import.meta.url)));
|
|
133
|
+
const node = await fs.realpath(process.execPath);
|
|
134
|
+
if (!(await fs.stat(cli)).isFile())
|
|
135
|
+
throw new Error('built CLI path required');
|
|
136
|
+
const files = ['pre-commit', 'pre-push'].map(name => path.join(metadata.hooks, name));
|
|
137
|
+
for (const file of files) {
|
|
138
|
+
try {
|
|
139
|
+
await fs.lstat(file);
|
|
140
|
+
throw new Error('existing Git hooks preserved; use manual integration');
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (error.code !== 'ENOENT')
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
await fs.mkdir(metadata.hooks, { recursive: true });
|
|
148
|
+
const common = `${quote(node)} ${quote(cli)} security check --policy ${quote(settings.policy)} --policy-digest ${quote(settings.policy_digest)} --evidence ${quote(settings.evidence)} --note ${quote(settings.note)}`;
|
|
149
|
+
const created = [];
|
|
150
|
+
try {
|
|
151
|
+
for (const [index, file] of files.entries()) {
|
|
152
|
+
const stage = index === 0 ? 'commit' : 'push';
|
|
153
|
+
const remote = index === 1 ? ' --remote "$1" --destination "$2"' : '';
|
|
154
|
+
await exclusive(file, `#!/bin/sh\n# yam.security-hook.v1\nexec ${common} --stage ${stage}${remote}\n`, 0o700);
|
|
155
|
+
created.push(file);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
for (const file of created)
|
|
160
|
+
await fs.unlink(file);
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
return { ok: true, result: 'ENABLED', hooks: ['pre-commit', 'pre-push'], enforcement: 'local_bypassable', ci_required: true };
|
|
164
|
+
}
|
|
165
|
+
async function safeParents(file) {
|
|
166
|
+
let current = path.dirname(path.resolve(file));
|
|
167
|
+
while (current !== path.dirname(current)) {
|
|
168
|
+
try {
|
|
169
|
+
const info = await fs.lstat(current);
|
|
170
|
+
if (info.isSymbolicLink() || !info.isDirectory())
|
|
171
|
+
throw new Error('unsafe security file parent');
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error.code !== 'ENOENT')
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
current = path.dirname(current);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function initialize(cwd, options) {
|
|
181
|
+
const metadata = await gitMetadata(cwd);
|
|
182
|
+
const base = path.join(metadata.root, '.yam', 'security');
|
|
183
|
+
const policyPath = path.resolve(metadata.root, options.get('--policy') || '.yam/security/policy.json');
|
|
184
|
+
const evidencePath = path.resolve(metadata.root, options.get('--evidence') || '.yam/security/check.json');
|
|
185
|
+
const notePath = path.resolve(metadata.root, options.get('--note') || '.yam/security/notes.md');
|
|
186
|
+
for (const target of [policyPath, evidencePath, notePath]) {
|
|
187
|
+
if (!target.startsWith(metadata.root + path.sep))
|
|
188
|
+
throw new Error('init files must be inside repository');
|
|
189
|
+
await safeParents(target);
|
|
190
|
+
}
|
|
191
|
+
// All areas have explicit trusted requirements; live environment inspection is deferred to deploy.
|
|
192
|
+
const policy = { version: 'security-check-v1', required_at: Object.fromEntries(SECURITY_AREA_IDS.map(id => [id, id === 'SEC-05' ? ['deploy'] : ['commit', 'push', 'deploy']])) };
|
|
193
|
+
const policyText = JSON.stringify(policy, null, 2) + '\n';
|
|
194
|
+
const binding = collectSecurityBinding(metadata.root, 'commit', options);
|
|
195
|
+
const starter = createSecurityStarter({ stage: 'commit', policy, binding: binding, environment: 'local', scope: ['initial project security assessment'], checker: 'operator-review-required' });
|
|
196
|
+
starter.policy_digest = hash(policyText);
|
|
197
|
+
const files = [policyPath, evidencePath, metadata.config];
|
|
198
|
+
for (const file of files) {
|
|
199
|
+
try {
|
|
200
|
+
await fs.lstat(file);
|
|
201
|
+
throw new Error('security init preserves existing files');
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error.code !== 'ENOENT')
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
await safeParents(path.join(base, 'placeholder'));
|
|
209
|
+
await fs.mkdir(base, { recursive: true });
|
|
210
|
+
const created = [];
|
|
211
|
+
try {
|
|
212
|
+
for (const [file, content] of [
|
|
213
|
+
[policyPath, policyText], [evidencePath, JSON.stringify(starter, null, 2) + '\n'],
|
|
214
|
+
[metadata.config, JSON.stringify({ schema: 'yam.security-install.v1', policy: policyPath, policy_digest: hash(policyText), evidence: evidencePath, note: notePath }, null, 2) + '\n']
|
|
215
|
+
]) {
|
|
216
|
+
await exclusive(file, content);
|
|
217
|
+
created.push(file);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
for (const file of created)
|
|
222
|
+
await fs.unlink(file);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, result: 'INITIALIZED', policy_digest: hash(policyText), evidence: path.relative(metadata.root, evidencePath), hooks_installed: false, evidence_boundary: 'operator_supplied_not_automatic_security_scan' };
|
|
226
|
+
}
|
|
227
|
+
async function verifyReferencedEvidence(cwd, input) {
|
|
228
|
+
const errors = [];
|
|
229
|
+
for (const area of input.areas || []) {
|
|
230
|
+
if (!SECURITY_AREA_IDS.includes(area.id)) {
|
|
231
|
+
errors.push('invalid_evidence_area');
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (area.status === 'passed' && !area.evidence?.length)
|
|
235
|
+
errors.push(`evidence_missing:${area.id}`);
|
|
236
|
+
for (const evidence of area.evidence || []) {
|
|
237
|
+
if (typeof evidence.reference !== 'string' || !evidence.reference || path.isAbsolute(evidence.reference) || /^[a-z]+:/i.test(evidence.reference)) {
|
|
238
|
+
errors.push(`evidence_reference_invalid:${area.id}`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const file = path.resolve(cwd, evidence.reference);
|
|
242
|
+
if (!file.startsWith(path.resolve(cwd) + path.sep)) {
|
|
243
|
+
errors.push(`evidence_outside_repository:${area.id}`);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
if (await digestFile(file, 16 * 1024 * 1024) !== evidence.sha256)
|
|
248
|
+
errors.push(`evidence_digest_mismatch:${area.id}`);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
errors.push(`evidence_unavailable:${area.id}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return errors;
|
|
256
|
+
}
|
|
257
|
+
export async function runSecurityCommand(args, settings = {}) {
|
|
258
|
+
const cwd = await fs.realpath(path.resolve(settings.cwd || process.cwd()));
|
|
259
|
+
const subcommand = args[0];
|
|
260
|
+
try {
|
|
261
|
+
if (subcommand === 'hooks') {
|
|
262
|
+
if (args[1] !== 'enable')
|
|
263
|
+
throw new Error('supported hook operation: enable');
|
|
264
|
+
return await enableHooks(cwd, parseArgs(args.slice(2)), settings.cliPath);
|
|
265
|
+
}
|
|
266
|
+
const options = parseArgs(args.slice(1));
|
|
267
|
+
if (subcommand === 'init')
|
|
268
|
+
return await initialize(cwd, options);
|
|
269
|
+
if (subcommand === 'status') {
|
|
270
|
+
const metadata = await gitMetadata(cwd);
|
|
271
|
+
let configured = false;
|
|
272
|
+
try {
|
|
273
|
+
const config = JSON.parse(await readSafe(metadata.config));
|
|
274
|
+
configured = config.schema === 'yam.security-install.v1';
|
|
275
|
+
}
|
|
276
|
+
catch { /* unavailable is reported */ }
|
|
277
|
+
const installed = [];
|
|
278
|
+
for (const name of ['pre-commit', 'pre-push']) {
|
|
279
|
+
try {
|
|
280
|
+
if ((await readSafe(path.join(metadata.hooks, name))).includes('# yam.security-hook.v1'))
|
|
281
|
+
installed.push(name);
|
|
282
|
+
}
|
|
283
|
+
catch { /* unavailable is reported */ }
|
|
284
|
+
}
|
|
285
|
+
return { ok: true, configured, local_hooks: installed, local_hooks_bypassable: true, remote_enforcement: 'not_measured', evidence_boundary: 'operator_supplied_not_automatic_security_scan' };
|
|
286
|
+
}
|
|
287
|
+
if (subcommand !== 'check')
|
|
288
|
+
throw new Error('supported commands: init, check, hooks enable, status');
|
|
289
|
+
return await check(cwd, options, settings);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
if (subcommand === 'check') {
|
|
293
|
+
const input = createSecurityStarter({ stage: 'commit', binding: { stage: 'commit', invalid_binding: true }, environment: 'local', scope: ['rejected security command'], checker: 'yam-security-check' });
|
|
294
|
+
const result = validateSecurityCheck(input);
|
|
295
|
+
result.errors.push('security_command_failed');
|
|
296
|
+
result.blocking.push('integration:security_command_failed');
|
|
297
|
+
result.result = 'BLOCKED';
|
|
298
|
+
try {
|
|
299
|
+
const persisted = await persistSecurityCheck(cwd, input, result);
|
|
300
|
+
return { ok: false, ...persisted, evidence_boundary: 'operator_supplied_not_automatic_security_scan' };
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return { ok: false, result: 'BLOCKED', errors: ['security_command_failed', 'security_note_persistence_failed'] };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return { ok: false, result: 'BLOCKED', errors: ['security_command_failed'], evidence_boundary: 'operator_supplied_not_automatic_security_scan' };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async function check(cwd, options, settings) {
|
|
310
|
+
const stage = options.get('--stage') || 'commit';
|
|
311
|
+
const normalizedStage = stage === 'ci' ? 'push' : stage;
|
|
312
|
+
let config = {};
|
|
313
|
+
try {
|
|
314
|
+
const metadata = await gitMetadata(cwd);
|
|
315
|
+
config = JSON.parse(await readSafe(metadata.config));
|
|
316
|
+
}
|
|
317
|
+
catch { /* an explicit trust pin can be provided instead */ }
|
|
318
|
+
const notePath = path.resolve(cwd, options.get('--note') || config.note || '.yam/security/notes.md');
|
|
319
|
+
let input;
|
|
320
|
+
let binding = { stage: normalizedStage, invalid_binding: true };
|
|
321
|
+
let policy = { version: 'security-check-v1', required_at: Object.fromEntries(SECURITY_AREA_IDS.map(id => [id, ['commit', 'push', 'deploy']])) };
|
|
322
|
+
let result;
|
|
323
|
+
const gateErrors = [];
|
|
324
|
+
try {
|
|
325
|
+
const policyFile = path.resolve(cwd, options.get('--policy') || config.policy || '.yam/security/policy.json');
|
|
326
|
+
const pin = options.get('--policy-digest') || ((stage !== 'ci' && stage !== 'deploy') ? config.policy_digest : undefined);
|
|
327
|
+
if (!pin || !DIGEST.test(pin))
|
|
328
|
+
throw new Error('policy_pin_missing');
|
|
329
|
+
const policyText = await readSafe(policyFile);
|
|
330
|
+
if (hash(policyText) !== pin)
|
|
331
|
+
throw new Error('policy_pin_mismatch');
|
|
332
|
+
policy = JSON.parse(policyText);
|
|
333
|
+
if (!policy || typeof policy.version !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(policy.version) || Object.keys(policy).some(key => !['version', 'required_at'].includes(key)) || !policy.required_at || Object.keys(policy.required_at).some(key => !SECURITY_AREA_IDS.includes(key)) || SECURITY_AREA_IDS.some(id => !Array.isArray(policy.required_at[id])))
|
|
334
|
+
throw new Error('policy_invalid');
|
|
335
|
+
for (const id of ['SEC-01', 'SEC-08'])
|
|
336
|
+
if (!['commit', 'push', 'deploy'].every(s => policy.required_at[id].includes(s)))
|
|
337
|
+
throw new Error('mandatory_policy_invalid');
|
|
338
|
+
for (const id of SECURITY_AREA_IDS)
|
|
339
|
+
if (!policy.required_at[id].length || policy.required_at[id].some(s => !['commit', 'push', 'deploy'].includes(s)))
|
|
340
|
+
throw new Error('policy_stage_invalid');
|
|
341
|
+
const stdin = stage === 'push' ? (settings.stdin ?? await readSafeStdin()) : '';
|
|
342
|
+
if (stage === 'deploy') {
|
|
343
|
+
for (const [fileOption, digestOption] of [['--artifact', '--artifact-digest'], ['--configuration', '--configuration-digest']]) {
|
|
344
|
+
const file = options.get(fileOption);
|
|
345
|
+
if (!file || await digestFile(path.resolve(cwd, file), 1024 * 1024 * 1024) !== options.get(digestOption))
|
|
346
|
+
throw new Error('deployment_file_digest_mismatch');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
binding = collectSecurityBinding(cwd, stage, options, stdin);
|
|
350
|
+
const evidenceText = await readSafe(path.resolve(cwd, options.get('--evidence') || config.evidence || '.yam/security/check.json'));
|
|
351
|
+
input = JSON.parse(evidenceText);
|
|
352
|
+
if (input.policy_digest !== pin)
|
|
353
|
+
gateErrors.push('policy_digest_mismatch');
|
|
354
|
+
if (input.stage !== normalizedStage)
|
|
355
|
+
gateErrors.push('stage_mismatch');
|
|
356
|
+
if (SECURITY_AREA_IDS.some(id => !input.areas?.some(area => area.id === id)))
|
|
357
|
+
gateErrors.push('area_coverage_missing');
|
|
358
|
+
if (input.areas?.some(area => ['SEC-01', 'SEC-08'].includes(area.id) && area.status === 'not_applicable'))
|
|
359
|
+
gateErrors.push('mandatory_area_not_applicable');
|
|
360
|
+
gateErrors.push(...await verifyReferencedEvidence(cwd, input));
|
|
361
|
+
input = { ...input, retry_of: input.run_id, run_id: `security-${randomUUID()}`, time: new Date().toISOString() };
|
|
362
|
+
result = validateSecurityCheck(input, binding, policy);
|
|
363
|
+
if (result.errors.includes('record_contains_secret')) {
|
|
364
|
+
input = createSecurityStarter({ stage: normalizedStage, binding: binding, environment: 'redacted', scope: ['unsafe supplied record rejected'], checker: 'yam-security-check' });
|
|
365
|
+
result = validateSecurityCheck(input, binding);
|
|
366
|
+
gateErrors.push('unsafe_security_record_rejected');
|
|
367
|
+
}
|
|
368
|
+
// Inspect the index/refs once more before persisting so mid-check changes cannot retain PASS.
|
|
369
|
+
const fresh = collectSecurityBinding(cwd, stage, options, stdin);
|
|
370
|
+
if (JSON.stringify(fresh) !== JSON.stringify(binding))
|
|
371
|
+
gateErrors.push('binding_changed_during_check');
|
|
372
|
+
if (stage === 'deploy') {
|
|
373
|
+
for (const [fileOption, digestOption] of [['--artifact', '--artifact-digest'], ['--configuration', '--configuration-digest']]) {
|
|
374
|
+
if (await digestFile(path.resolve(cwd, options.get(fileOption)), 1024 * 1024 * 1024) !== options.get(digestOption))
|
|
375
|
+
gateErrors.push('deployment_file_changed_during_check');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
input = createSecurityStarter({ stage: normalizedStage, binding: binding, environment: stage === 'deploy' ? options.get('--environment') || 'unavailable' : 'local', scope: ['security gate unavailable'], checker: 'yam-security-check' });
|
|
381
|
+
result = validateSecurityCheck(input, binding);
|
|
382
|
+
gateErrors.push('security_input_or_binding_unavailable');
|
|
383
|
+
}
|
|
384
|
+
if (gateErrors.length) {
|
|
385
|
+
result.result = 'BLOCKED';
|
|
386
|
+
result.errors = [...new Set([...result.errors, ...gateErrors])];
|
|
387
|
+
result.blocking = [...new Set([...result.blocking, ...gateErrors.map(e => `integration:${e}`)])];
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
const persisted = await persistSecurityCheck(cwd, input, result, { notePath });
|
|
391
|
+
return { ok: persisted.result.result === 'PASS', ...persisted, evidence_boundary: 'operator_supplied_not_automatic_security_scan' };
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return { ok: false, result: 'BLOCKED', errors: ['security_note_persistence_failed'], evidence_boundary: 'operator_supplied_not_automatic_security_scan' };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async function readSafeStdin() {
|
|
398
|
+
const text = readFileSync(0, 'utf8');
|
|
399
|
+
if (text.length > 128 * 1024)
|
|
400
|
+
throw new Error('push_ref_input_too_large');
|
|
401
|
+
return text;
|
|
402
|
+
}
|
|
403
|
+
async function digestFile(file, limit) {
|
|
404
|
+
await safeParents(file);
|
|
405
|
+
const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
406
|
+
try {
|
|
407
|
+
const info = await handle.stat();
|
|
408
|
+
if (!info.isFile() || info.size > limit)
|
|
409
|
+
throw new Error('invalid digest target');
|
|
410
|
+
const digest = createHash('sha256');
|
|
411
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
412
|
+
let position = 0;
|
|
413
|
+
while (position < info.size) {
|
|
414
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, info.size - position), position);
|
|
415
|
+
if (!bytesRead)
|
|
416
|
+
throw new Error('digest target changed during read');
|
|
417
|
+
digest.update(buffer.subarray(0, bytesRead));
|
|
418
|
+
position += bytesRead;
|
|
419
|
+
}
|
|
420
|
+
const after = await handle.stat();
|
|
421
|
+
if (after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs)
|
|
422
|
+
throw new Error('digest target changed during read');
|
|
423
|
+
return `sha256:${digest.digest('hex')}`;
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
await handle.close();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
@@ -72,6 +72,35 @@ export type TransactionalInstallResult = {
|
|
|
72
72
|
sourceDigest: string;
|
|
73
73
|
cleanupWarnings: string[];
|
|
74
74
|
};
|
|
75
|
+
export type SkillInstallPlanOperation = {
|
|
76
|
+
action: 'create' | 'replace_managed' | 'replace_explicitly_authorized' | 'remove_retired' | 'write_receipt';
|
|
77
|
+
skill: string;
|
|
78
|
+
path: string;
|
|
79
|
+
reason: string;
|
|
80
|
+
};
|
|
81
|
+
export type SkillInstallPlan = {
|
|
82
|
+
schema: 'yam.install-plan.v1';
|
|
83
|
+
generated_at: string;
|
|
84
|
+
mutation_authorized: false;
|
|
85
|
+
package: {
|
|
86
|
+
name: string;
|
|
87
|
+
version: string;
|
|
88
|
+
};
|
|
89
|
+
source: {
|
|
90
|
+
root: string;
|
|
91
|
+
digest: string;
|
|
92
|
+
file_count: number;
|
|
93
|
+
};
|
|
94
|
+
destination: string;
|
|
95
|
+
receipt_path: string;
|
|
96
|
+
operations: SkillInstallPlanOperation[];
|
|
97
|
+
preserved_paths: string[];
|
|
98
|
+
blockers: string[];
|
|
99
|
+
ready: boolean;
|
|
100
|
+
truth_status: 'verified' | 'blocked';
|
|
101
|
+
plan_digest: string;
|
|
102
|
+
next_action: string;
|
|
103
|
+
};
|
|
75
104
|
export type SafeUninstallOptions = {
|
|
76
105
|
destination: string;
|
|
77
106
|
codexMirror?: string;
|
|
@@ -89,5 +118,6 @@ export type SafeUninstallResult = {
|
|
|
89
118
|
};
|
|
90
119
|
export declare function findInstallRecoveryArtifacts(root: string): Promise<string[]>;
|
|
91
120
|
export declare function inspectSkillInstallation(options: InstallationInspectionOptions): Promise<InstallationInspection>;
|
|
121
|
+
export declare function planSkillSetInstallation(options: TransactionalInstallOptions): Promise<SkillInstallPlan>;
|
|
92
122
|
export declare function installSkillSetTransactional(options: TransactionalInstallOptions): Promise<TransactionalInstallResult>;
|
|
93
123
|
export declare function uninstallSkillSetSafely(options: SafeUninstallOptions): Promise<SafeUninstallResult>;
|