agentic-workflow-manager 9.5.0 → 9.6.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.
- package/dist/src/commands/preflight/checks.js +2 -2
- package/dist/src/commands/sensors/bootstrap.js +171 -0
- package/dist/src/commands/sensors/compatibility/contract.js +18 -15
- package/dist/src/commands/sensors/compatibility/manifest.js +29 -1
- package/dist/src/commands/sensors/compatibility/materialize.js +256 -54
- package/dist/src/commands/sensors/compatibility/safe-file.js +240 -5
- package/dist/src/commands/sensors/coverage/index.js +2 -2
- package/dist/src/commands/sensors/coverage/resolve.js +20 -10
- package/dist/src/commands/sensors/detection.js +31 -0
- package/dist/src/commands/sensors/exec.js +116 -11
- package/dist/src/commands/sensors/index.js +51 -10
- package/dist/src/commands/sensors/init.js +17 -170
- package/dist/src/commands/sensors/migrate.js +209 -0
- package/dist/src/commands/sensors/run.js +2 -2
- package/dist/src/core/init/steps.js +24 -11
- package/dist/src/core/secure-fs/native-bridge.js +209 -0
- package/dist/tests/commands/sensors/bootstrap-observational.test.js +24 -0
- package/dist/tests/commands/sensors/bootstrap.test.js +175 -0
- package/dist/tests/commands/sensors/compatibility/contract.test.js +38 -0
- package/dist/tests/commands/sensors/compatibility/manifest.test.js +67 -0
- package/dist/tests/commands/sensors/compatibility/materialize.test.js +410 -0
- package/dist/tests/commands/sensors/compatibility/pack-source.test.js +34 -1
- package/dist/tests/commands/sensors/compatibility/safe-file.test.js +52 -0
- package/dist/tests/commands/sensors/coverage/index.test.js +20 -0
- package/dist/tests/commands/sensors/coverage/resolve.test.js +10 -0
- package/dist/tests/commands/sensors/exec.test.js +86 -0
- package/dist/tests/commands/sensors/index.test.js +54 -0
- package/dist/tests/commands/sensors/init-pack-unavailable.test.js +21 -84
- package/dist/tests/commands/sensors/init.test.js +56 -490
- package/dist/tests/commands/sensors/migrate.test.js +513 -0
- package/dist/tests/core/init/orchestrator.test.js +3 -1
- package/dist/tests/core/secure-fs/native-bridge.test.js +733 -0
- package/dist/tests/integration/sensor-bootstrap.e2e.test.js +165 -0
- package/dist/tests/integration/sensor-compatibility.e2e.test.js +10 -2
- package/dist/tests/integration/sensor-structured-run.e2e.test.js +3 -1
- package/dist/tests/structural/native-release-artifacts.test.js +22 -0
- package/dist/tests/structural/release-flags-are-reachable-from-ci.test.js +10 -0
- package/dist/tests/structural/release-registry-provenance.test.js +4 -0
- package/dist/tests/structural/sensor-portability-contract.test.js +44 -0
- package/package.json +6 -2
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/linux-arm64/secure_fs.node +0 -0
- package/prebuilds/linux-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -3,18 +3,22 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.materializePortableSensors = materializePortableSensors;
|
|
6
7
|
exports.materializeResolvedSensors = materializeResolvedSensors;
|
|
7
8
|
const fs_1 = __importDefault(require("fs"));
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
9
10
|
const manifest_1 = require("./manifest");
|
|
10
11
|
const contract_1 = require("./contract");
|
|
12
|
+
const safe_file_1 = require("./safe-file");
|
|
13
|
+
const native_bridge_1 = require("../../../core/secure-fs/native-bridge");
|
|
14
|
+
const MAX_ASSET_BYTES = 1024 * 1024;
|
|
11
15
|
function stableId(value, label) {
|
|
12
16
|
if (typeof value !== 'string' || !/^[a-z][a-z0-9-]*$/.test(value))
|
|
13
17
|
throw new Error(`${label} must be a stable lowercase id`);
|
|
14
18
|
return value;
|
|
15
19
|
}
|
|
16
20
|
function containedAsset(value, label) {
|
|
17
|
-
if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || path_1.default.isAbsolute(value) || value.split('/').some(part => !part || part === '.' || part === '..'))
|
|
21
|
+
if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || path_1.default.isAbsolute(value) || /^(?:[A-Za-z]:|[\\/])/.test(value) || value.split('/').some(part => !part || part === '.' || part === '..'))
|
|
18
22
|
throw new Error(`${label} must be a contained relative asset path`);
|
|
19
23
|
return value;
|
|
20
24
|
}
|
|
@@ -31,45 +35,266 @@ function root(value, label) {
|
|
|
31
35
|
}
|
|
32
36
|
return resolved;
|
|
33
37
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
/** Resolve a monorepo package only when it is a real directory below the project.
|
|
39
|
+
* A lexical `foo/bar` is not enough: a symlink must never turn an asset write
|
|
40
|
+
* into an authority outside the project transaction. */
|
|
41
|
+
function containedPackageRoot(projectRoot, packageRoot) {
|
|
42
|
+
const candidate = path_1.default.join(projectRoot, packageRoot);
|
|
43
|
+
let stat;
|
|
38
44
|
try {
|
|
39
|
-
fs_1.default.
|
|
40
|
-
fs_1.default.renameSync(temporary, destination);
|
|
45
|
+
stat = fs_1.default.lstatSync(candidate);
|
|
41
46
|
}
|
|
42
|
-
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error('packageRoot must be an existing contained directory');
|
|
49
|
+
}
|
|
50
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
51
|
+
throw new Error('packageRoot must be a non-symbolic contained directory');
|
|
52
|
+
const realProject = fs_1.default.realpathSync(projectRoot);
|
|
53
|
+
const realPackage = fs_1.default.realpathSync(candidate);
|
|
54
|
+
const relative = path_1.default.relative(realProject, realPackage);
|
|
55
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path_1.default.sep}`) || path_1.default.isAbsolute(relative)) {
|
|
56
|
+
throw new Error('packageRoot must be a contained directory');
|
|
57
|
+
}
|
|
58
|
+
return realPackage;
|
|
59
|
+
}
|
|
60
|
+
function registryPackRoot(value) {
|
|
61
|
+
const resolved = root(value, 'packRoot');
|
|
62
|
+
const parsed = path_1.default.parse(resolved);
|
|
63
|
+
let current = parsed.root;
|
|
64
|
+
for (const component of path_1.default.relative(parsed.root, resolved).split(path_1.default.sep).filter(Boolean)) {
|
|
65
|
+
let stat;
|
|
43
66
|
try {
|
|
44
|
-
|
|
45
|
-
|
|
67
|
+
stat = fs_1.default.lstatSync(current);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error('packRoot must be an existing directory');
|
|
46
71
|
}
|
|
47
|
-
|
|
72
|
+
if (stat.isSymbolicLink())
|
|
73
|
+
throw new Error('packRoot contains a symlink');
|
|
74
|
+
current = path_1.default.join(current, component);
|
|
75
|
+
}
|
|
76
|
+
let stat;
|
|
77
|
+
try {
|
|
78
|
+
stat = fs_1.default.lstatSync(current);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new Error('packRoot must be an existing directory');
|
|
82
|
+
}
|
|
83
|
+
if (stat.isSymbolicLink())
|
|
84
|
+
throw new Error('packRoot contains a symlink');
|
|
85
|
+
return resolved;
|
|
86
|
+
}
|
|
87
|
+
function atomicWrite(configRoot, asset, content, noClobber = false) {
|
|
88
|
+
const destination = path_1.default.join(configRoot, ...asset.split('/'));
|
|
89
|
+
if (noClobber) {
|
|
90
|
+
(0, safe_file_1.writeProjectFile)(configRoot, asset, Buffer.from(content, 'utf8'), { mode: 'create', createParents: true });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
let expected;
|
|
94
|
+
try {
|
|
95
|
+
expected = (0, safe_file_1.readInspectedBoundedFileWithIdentity)(destination, fs_1.default.lstatSync(destination, { bigint: true }), MAX_ASSET_BYTES, () => new Error(`project destination cannot be safely read: ${asset}`));
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code !== 'ENOENT')
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
(0, safe_file_1.writeProjectFile)(configRoot, asset, Buffer.from(content, 'utf8'), expected
|
|
102
|
+
? { mode: 'replace', expected: expected.content, expectedIdentity: expected.identity, createParents: false }
|
|
103
|
+
: { mode: 'create', createParents: true });
|
|
104
|
+
}
|
|
105
|
+
/** Publish a new asset without ever replacing an owner-created destination. */
|
|
106
|
+
function atomicCreateAsset(configRoot, asset, content) {
|
|
107
|
+
try {
|
|
108
|
+
(0, safe_file_1.writeProjectFile)(configRoot, asset, Buffer.from(content, 'utf8'), { mode: 'create', createParents: true });
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (error.message === `project destination rejected by secure-fs: ${native_bridge_1.PROJECT_DESTINATION_ALREADY_EXISTS_MESSAGE}`)
|
|
112
|
+
return false;
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
function selectedRegistryAsset(packRoot, asset) {
|
|
118
|
+
let current = packRoot;
|
|
119
|
+
for (const [index, component] of asset.split('/').entries()) {
|
|
120
|
+
let stat;
|
|
121
|
+
try {
|
|
122
|
+
stat = fs_1.default.lstatSync(current, { bigint: true });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new Error(`selected asset is missing from pack: ${asset}`);
|
|
126
|
+
}
|
|
127
|
+
if (stat.isSymbolicLink())
|
|
128
|
+
throw new Error(`selected asset contains a symlink: ${asset}`);
|
|
129
|
+
if (index < asset.split('/').length - 1 && !stat.isDirectory())
|
|
130
|
+
throw new Error(`selected asset is missing from pack: ${asset}`);
|
|
131
|
+
current = path_1.default.join(current, component);
|
|
132
|
+
}
|
|
133
|
+
let stat;
|
|
134
|
+
try {
|
|
135
|
+
stat = fs_1.default.lstatSync(current, { bigint: true });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
throw new Error(`selected asset is missing from pack: ${asset}`);
|
|
139
|
+
}
|
|
140
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
141
|
+
throw new Error(`selected asset must be a regular file: ${asset}`);
|
|
142
|
+
if (stat.size > BigInt(MAX_ASSET_BYTES))
|
|
143
|
+
throw new Error(`selected asset exceeds the 1 MiB limit: ${asset}`);
|
|
144
|
+
return { file: current, stat };
|
|
145
|
+
}
|
|
146
|
+
function readSelectedRegistryAsset(packRoot, selected, asset) {
|
|
147
|
+
// Re-walk the bounded parent chain immediately before the leaf-safe-open.
|
|
148
|
+
// A replaced ancestor can otherwise redirect the same pathname outside the
|
|
149
|
+
// inspected registry between selection and the O_NOFOLLOW leaf open.
|
|
150
|
+
const current = selectedRegistryAsset(packRoot, asset);
|
|
151
|
+
if (current.stat.dev !== selected.stat.dev || current.stat.ino !== selected.stat.ino) {
|
|
152
|
+
throw new Error(`selected asset changed identity during safe open: ${asset}`);
|
|
153
|
+
}
|
|
154
|
+
const failure = (reason) => {
|
|
155
|
+
if (reason === 'open')
|
|
156
|
+
return new Error(`selected asset cannot be safely opened: ${asset}`);
|
|
157
|
+
if (reason === 'regular')
|
|
158
|
+
return new Error(`selected asset must be a regular file: ${asset}`);
|
|
159
|
+
if (reason === 'identity')
|
|
160
|
+
return new Error(`selected asset changed identity during safe open: ${asset}`);
|
|
161
|
+
if (reason === 'size')
|
|
162
|
+
return new Error(`selected asset changed size during safe open: ${asset}`);
|
|
163
|
+
return new Error(`selected asset exceeds the 1 MiB limit: ${asset}`);
|
|
164
|
+
};
|
|
165
|
+
return (0, safe_file_1.readInspectedBoundedFile)(selected.file, selected.stat, MAX_ASSET_BYTES, failure).toString('utf8');
|
|
166
|
+
}
|
|
167
|
+
function portableSource(source, pack) {
|
|
168
|
+
if (!source || typeof source !== 'object')
|
|
169
|
+
throw new Error('portable source is required');
|
|
170
|
+
const candidate = source;
|
|
171
|
+
if (!candidate.registry || typeof candidate.registry !== 'object' || typeof candidate.registry.name !== 'string' || typeof candidate.registry.contentRoot !== 'string' || typeof candidate.path !== 'string') {
|
|
172
|
+
throw new Error('portable source must be a validated pack source');
|
|
173
|
+
}
|
|
174
|
+
const registry = stableId(candidate.registry.name, 'source.registry.name');
|
|
175
|
+
const configuredRoot = root(candidate.registry.contentRoot, 'source.registry.contentRoot');
|
|
176
|
+
let contentRoot;
|
|
177
|
+
try {
|
|
178
|
+
contentRoot = fs_1.default.realpathSync(configuredRoot);
|
|
48
179
|
}
|
|
180
|
+
catch {
|
|
181
|
+
throw new Error('source.registry.contentRoot must be canonicalizable');
|
|
182
|
+
}
|
|
183
|
+
const packRoot = registryPackRoot(path_1.default.join(contentRoot, 'sensor-packs', pack));
|
|
184
|
+
const expectedPack = path_1.default.join(packRoot, 'pack.json');
|
|
185
|
+
if (path_1.default.resolve(candidate.path) !== expectedPack)
|
|
186
|
+
throw new Error('portable source selected pack path does not match its registry');
|
|
187
|
+
const inspectedPack = selectedRegistryAsset(packRoot, 'pack.json');
|
|
188
|
+
if (readSelectedRegistryAsset(packRoot, inspectedPack, 'pack.json') !== candidate.content) {
|
|
189
|
+
throw new Error('portable source changed after bootstrap planning');
|
|
190
|
+
}
|
|
191
|
+
return { registry, packRoot };
|
|
49
192
|
}
|
|
50
193
|
function priorAssets(projectRoot) {
|
|
51
194
|
const manifest = path_1.default.join(projectRoot, '.awm', 'sensors.json');
|
|
52
|
-
|
|
53
|
-
|
|
195
|
+
let stat;
|
|
196
|
+
try {
|
|
197
|
+
stat = fs_1.default.lstatSync(manifest, { bigint: true });
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error.code === 'ENOENT')
|
|
201
|
+
return [];
|
|
202
|
+
throw new Error(`cannot materialize over invalid sensor manifest ${manifest}`);
|
|
203
|
+
}
|
|
204
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
205
|
+
throw new Error(`cannot materialize over invalid sensor manifest ${manifest}`);
|
|
54
206
|
let parsed;
|
|
55
207
|
try {
|
|
56
|
-
parsed = JSON.parse(
|
|
208
|
+
parsed = JSON.parse((0, safe_file_1.readInspectedBoundedFile)(manifest, stat, MAX_ASSET_BYTES, () => new Error(`cannot materialize over invalid sensor manifest ${manifest}`)).toString('utf8'));
|
|
57
209
|
}
|
|
58
210
|
catch {
|
|
59
211
|
throw new Error(`cannot materialize over invalid sensor manifest ${manifest}`);
|
|
60
212
|
}
|
|
61
213
|
const parsedManifest = (0, manifest_1.parseSensorManifest)(parsed, manifest);
|
|
62
|
-
if (parsedManifest.kind
|
|
63
|
-
return [];
|
|
64
|
-
|
|
214
|
+
if (parsedManifest.kind === 'v2')
|
|
215
|
+
return Object.values(parsedManifest.pack.sensors).flatMap(sensor => sensor.assets ?? []);
|
|
216
|
+
if (parsedManifest.kind === 'v3' && parsedManifest.pack.mode === 'project-sensors')
|
|
217
|
+
return Object.values(parsedManifest.pack.sensors).flatMap(sensor => sensor.assets ?? []);
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
/** Materialize a previously selected logical registry source into a portable v3 manifest. */
|
|
221
|
+
function materializePortableSensors(input) {
|
|
222
|
+
if (!input || typeof input !== 'object')
|
|
223
|
+
throw new Error('materialization input is required');
|
|
224
|
+
const projectRoot = root(input.projectRoot, 'projectRoot');
|
|
225
|
+
const pack = stableId(input.pack, 'pack');
|
|
226
|
+
const source = portableSource(input.source, pack);
|
|
227
|
+
const packRoot = source.packRoot;
|
|
228
|
+
if (!input.sensors || typeof input.sensors !== 'object' || Array.isArray(input.sensors))
|
|
229
|
+
throw new Error('sensors must be an object');
|
|
230
|
+
const candidate = {
|
|
231
|
+
schemaVersion: 3, mode: 'project-sensors', pack,
|
|
232
|
+
...(input.packSelection === 'explicit' ? { packSelection: 'explicit' } : {}),
|
|
233
|
+
source: { registry: source.registry },
|
|
234
|
+
...(input.packageRoot ? { packageRoot: containedAsset(input.packageRoot, 'packageRoot') } : {}),
|
|
235
|
+
sensors: input.sensors,
|
|
236
|
+
};
|
|
237
|
+
const parsed = (0, manifest_1.parseSensorManifest)(candidate, 'portable materialized manifest');
|
|
238
|
+
if (parsed.kind !== 'v3' || parsed.pack.mode !== 'project-sensors')
|
|
239
|
+
throw new Error('portable materialized manifest must be v3 project-sensors');
|
|
240
|
+
const manifest = parsed.pack;
|
|
241
|
+
for (const [name, sensor] of Object.entries(manifest.sensors)) {
|
|
242
|
+
if (sensor.policyRef)
|
|
243
|
+
(0, contract_1.resolveSemgrepPolicy)(sensor.policyRef, path_1.default.join(packRoot, 'pack.json'), `sensors.${name}`);
|
|
244
|
+
}
|
|
245
|
+
const selected = [...new Set(Object.values(manifest.sensors).flatMap(sensor => sensor.assets ?? []))].map((asset, index) => containedAsset(asset, `assets[${index}]`)).sort();
|
|
246
|
+
// Establish every registry authority and read every selected byte before the
|
|
247
|
+
// first project mutation. A bad later asset must not strand an earlier one.
|
|
248
|
+
const publications = input.configure === false ? [] : selected.map(asset => {
|
|
249
|
+
const selectedSource = selectedRegistryAsset(packRoot, asset);
|
|
250
|
+
return { asset, content: readSelectedRegistryAsset(packRoot, selectedSource, asset) };
|
|
251
|
+
});
|
|
252
|
+
return (0, safe_file_1.withProjectLease)(projectRoot, () => {
|
|
253
|
+
const configRoot = manifest.packageRoot ? containedPackageRoot(projectRoot, manifest.packageRoot) : projectRoot;
|
|
254
|
+
const prior = priorAssets(projectRoot);
|
|
255
|
+
const configured = [];
|
|
256
|
+
const preserved = [];
|
|
257
|
+
const created = [];
|
|
258
|
+
try {
|
|
259
|
+
if (input.configure !== false) {
|
|
260
|
+
for (const publication of publications) {
|
|
261
|
+
if (atomicCreateAsset(configRoot, publication.asset, publication.content)) {
|
|
262
|
+
const destination = manifest.packageRoot ? `${manifest.packageRoot}/${publication.asset}` : publication.asset;
|
|
263
|
+
const absolute = path_1.default.join(projectRoot, ...destination.split('/'));
|
|
264
|
+
const stat = fs_1.default.lstatSync(absolute, { bigint: true });
|
|
265
|
+
const observed = (0, safe_file_1.readInspectedBoundedFileWithIdentity)(absolute, stat, MAX_ASSET_BYTES, () => new Error(`created asset cannot be safely observed: ${publication.asset}`));
|
|
266
|
+
if (!observed.content.equals(Buffer.from(publication.content, 'utf8'))) {
|
|
267
|
+
throw new Error(`created asset changed before manifest publication: ${publication.asset}`);
|
|
268
|
+
}
|
|
269
|
+
created.push({ destination, identity: observed.identity });
|
|
270
|
+
configured.push(publication.asset);
|
|
271
|
+
}
|
|
272
|
+
else
|
|
273
|
+
preserved.push(publication.asset);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
atomicWrite(projectRoot, '.awm/sensors.json', (0, manifest_1.serializeManifestV3)(manifest), true);
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
for (const entry of created.reverse()) {
|
|
280
|
+
try {
|
|
281
|
+
(0, safe_file_1.removeObservedProjectFile)(projectRoot, entry.destination, entry.identity);
|
|
282
|
+
}
|
|
283
|
+
catch { /* Preserve a competing replacement; report the original publication failure. */ }
|
|
284
|
+
}
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
return { manifest, configured, preserved, orphaned: prior.filter(asset => !selected.includes(asset)).sort() };
|
|
288
|
+
});
|
|
65
289
|
}
|
|
66
290
|
/** Write a selected, v2-only sensor configuration without overwriting user files.
|
|
67
|
-
*
|
|
291
|
+
* Assets published before a manifest failure are deliberately left in place: a
|
|
292
|
+
* pathname rollback cannot be made safe after a concurrent project-parent swap. */
|
|
68
293
|
function materializeResolvedSensors(input) {
|
|
69
294
|
if (!input || typeof input !== 'object')
|
|
70
295
|
throw new Error('materialization input is required');
|
|
71
296
|
const projectRoot = root(input.projectRoot, 'projectRoot');
|
|
72
|
-
const packRoot =
|
|
297
|
+
const packRoot = registryPackRoot(input.packRoot);
|
|
73
298
|
const pack = stableId(input.pack, 'pack');
|
|
74
299
|
if (!input.sensors || typeof input.sensors !== 'object' || Array.isArray(input.sensors))
|
|
75
300
|
throw new Error('sensors must be an object');
|
|
@@ -86,7 +311,6 @@ function materializeResolvedSensors(input) {
|
|
|
86
311
|
// Assets land where sensors will actually run — projectRoot by default, or
|
|
87
312
|
// packageRoot underneath it for a monorepo. The manifest write below stays
|
|
88
313
|
// pinned to projectRoot either way (see atomicWrite call at the end).
|
|
89
|
-
const configRoot = manifest.packageRoot ? root(path_1.default.join(projectRoot, manifest.packageRoot), 'packageRoot') : projectRoot;
|
|
90
314
|
// A policy reference is deliberately not a materialized asset. Resolve it here
|
|
91
315
|
// from the registry-owned sibling only, so a manifest cannot turn arbitrary
|
|
92
316
|
// registry content into a project write through a cosmetic policy field.
|
|
@@ -95,44 +319,22 @@ function materializeResolvedSensors(input) {
|
|
|
95
319
|
(0, contract_1.resolveSemgrepPolicy)(sensor.policyRef, path_1.default.join(packRoot, 'pack.json'), `sensors.${name}`);
|
|
96
320
|
}
|
|
97
321
|
const selected = [...new Set(Object.values(sensors).flatMap(sensor => sensor.assets ?? []))].map((asset, index) => containedAsset(asset, `assets[${index}]`)).sort();
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
322
|
+
return (0, safe_file_1.withProjectLease)(projectRoot, () => {
|
|
323
|
+
const configRoot = manifest.packageRoot ? root(path_1.default.join(projectRoot, manifest.packageRoot), 'packageRoot') : projectRoot;
|
|
324
|
+
const prior = priorAssets(projectRoot);
|
|
325
|
+
const configured = [];
|
|
326
|
+
const preserved = [];
|
|
103
327
|
if (input.configure !== false) {
|
|
104
328
|
for (const asset of selected) {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
try {
|
|
109
|
-
stat = fs_1.default.lstatSync(source);
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
throw new Error(`selected asset is missing from pack: ${asset}`);
|
|
329
|
+
const selectedSource = selectedRegistryAsset(packRoot, asset);
|
|
330
|
+
if (atomicCreateAsset(configRoot, asset, readSelectedRegistryAsset(packRoot, selectedSource, asset))) {
|
|
331
|
+
configured.push(asset);
|
|
113
332
|
}
|
|
114
|
-
|
|
115
|
-
throw new Error(`selected asset must be a regular file: ${asset}`);
|
|
116
|
-
if (fs_1.default.existsSync(destination)) {
|
|
333
|
+
else
|
|
117
334
|
preserved.push(asset);
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
fs_1.default.mkdirSync(path_1.default.dirname(destination), { recursive: true });
|
|
121
|
-
atomicWrite(destination, fs_1.default.readFileSync(source, 'utf8'));
|
|
122
|
-
created.push(destination);
|
|
123
|
-
configured.push(asset);
|
|
124
335
|
}
|
|
125
336
|
}
|
|
126
|
-
atomicWrite(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
for (const file of created.reverse()) {
|
|
130
|
-
try {
|
|
131
|
-
fs_1.default.unlinkSync(file);
|
|
132
|
-
}
|
|
133
|
-
catch { /* best effort transaction rollback */ }
|
|
134
|
-
}
|
|
135
|
-
throw error;
|
|
136
|
-
}
|
|
137
|
-
return { manifest, configured, preserved, orphaned: prior.filter(asset => !selected.includes(asset)).sort() };
|
|
337
|
+
atomicWrite(projectRoot, '.awm/sensors.json', (0, manifest_1.serializeManifestV2)(manifest));
|
|
338
|
+
return { manifest, configured, preserved, orphaned: prior.filter(asset => !selected.includes(asset)).sort() };
|
|
339
|
+
});
|
|
138
340
|
}
|
|
@@ -3,16 +3,226 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.setSecureFsForTests = setSecureFsForTests;
|
|
7
|
+
exports.setPortableReadForTests = setPortableReadForTests;
|
|
8
|
+
exports.withProjectLease = withProjectLease;
|
|
9
|
+
exports.writeProjectFile = writeProjectFile;
|
|
10
|
+
exports.removeObservedProjectFile = removeObservedProjectFile;
|
|
11
|
+
exports.withNoFollowDirectory = withNoFollowDirectory;
|
|
12
|
+
exports.withDescriptorBoundWriteDirectory = withDescriptorBoundWriteDirectory;
|
|
13
|
+
exports.withNoFollowChildDirectory = withNoFollowChildDirectory;
|
|
6
14
|
exports.readInspectedBoundedFile = readInspectedBoundedFile;
|
|
15
|
+
exports.readInspectedBoundedFileWithIdentity = readInspectedBoundedFileWithIdentity;
|
|
7
16
|
const fs_1 = __importDefault(require("fs"));
|
|
17
|
+
const path_1 = __importDefault(require("path"));
|
|
18
|
+
const native_bridge_1 = require("../../../core/secure-fs/native-bridge");
|
|
19
|
+
let secureFsForTest;
|
|
20
|
+
let portableReadForTests = false;
|
|
21
|
+
/** Test-only seam for filesystem race fixtures. Production always uses the packaged bridge. */
|
|
22
|
+
function setSecureFsForTests(boundary) {
|
|
23
|
+
secureFsForTest = boundary;
|
|
24
|
+
}
|
|
25
|
+
/** Test-only opt-in for Node-level filesystem race fixtures. */
|
|
26
|
+
function setPortableReadForTests(enabled) {
|
|
27
|
+
portableReadForTests = enabled;
|
|
28
|
+
}
|
|
29
|
+
function activeSecureFs() {
|
|
30
|
+
return secureFsForTest ?? native_bridge_1.secureFs;
|
|
31
|
+
}
|
|
32
|
+
/** Hold the native project lease for one complete sensor mutation callback. */
|
|
33
|
+
function withProjectLease(projectRoot, operation) {
|
|
34
|
+
return activeSecureFs().withProjectLease(projectRoot, operation);
|
|
35
|
+
}
|
|
36
|
+
/** Native-only project publication. Production never falls back to pathname writes. */
|
|
37
|
+
function writeProjectFile(projectRoot, destination, content, options) {
|
|
38
|
+
try {
|
|
39
|
+
activeSecureFs().writeProjectTransaction(projectRoot, destination, content, options);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new Error(`project destination rejected by secure-fs: ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Compensate only a file that this transaction read and identity-fenced. */
|
|
46
|
+
function removeObservedProjectFile(projectRoot, destination, identity) {
|
|
47
|
+
try {
|
|
48
|
+
const remove = activeSecureFs().removeObservedProjectFile;
|
|
49
|
+
if (typeof remove !== 'function')
|
|
50
|
+
throw new Error('secure-fs native bridge does not support identity-fenced removal');
|
|
51
|
+
remove(projectRoot, destination, identity);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`project destination rejected by secure-fs: ${error.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function sameIdentity(left, right) {
|
|
58
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
59
|
+
}
|
|
60
|
+
function snapshotDirectoryChain(directory, failure) {
|
|
61
|
+
if (!path_1.default.isAbsolute(directory))
|
|
62
|
+
throw failure();
|
|
63
|
+
const resolved = path_1.default.resolve(directory);
|
|
64
|
+
const parsed = path_1.default.parse(resolved);
|
|
65
|
+
const snapshots = [];
|
|
66
|
+
let current = parsed.root;
|
|
67
|
+
for (const component of ['', ...path_1.default.relative(parsed.root, resolved).split(path_1.default.sep).filter(Boolean)]) {
|
|
68
|
+
if (component)
|
|
69
|
+
current = path_1.default.join(current, component);
|
|
70
|
+
let stat;
|
|
71
|
+
try {
|
|
72
|
+
stat = fs_1.default.lstatSync(current, { bigint: true });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw failure();
|
|
76
|
+
}
|
|
77
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
78
|
+
throw failure();
|
|
79
|
+
snapshots.push({ name: current, stat });
|
|
80
|
+
}
|
|
81
|
+
return snapshots;
|
|
82
|
+
}
|
|
83
|
+
function assertDirectoryChainUnchanged(snapshots, failure) {
|
|
84
|
+
for (const snapshot of snapshots) {
|
|
85
|
+
let current;
|
|
86
|
+
try {
|
|
87
|
+
current = fs_1.default.lstatSync(snapshot.name, { bigint: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw failure();
|
|
91
|
+
}
|
|
92
|
+
if (!current.isDirectory() || current.isSymbolicLink() || !sameIdentity(current, snapshot.stat))
|
|
93
|
+
throw failure();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function withPortableValidatedDirectory(directory, failure, operation) {
|
|
97
|
+
const snapshots = snapshotDirectoryChain(directory, failure);
|
|
98
|
+
let result;
|
|
99
|
+
let operationError;
|
|
100
|
+
try {
|
|
101
|
+
result = operation(directory);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
operationError = error;
|
|
105
|
+
}
|
|
106
|
+
assertDirectoryChainUnchanged(snapshots, failure);
|
|
107
|
+
if (operationError !== undefined)
|
|
108
|
+
throw operationError;
|
|
109
|
+
return result;
|
|
110
|
+
}
|
|
111
|
+
function procfsDescriptorBridgeAvailable() {
|
|
112
|
+
try {
|
|
113
|
+
return process.platform === 'linux' && fs_1.default.statSync('/proc/self/fd').isDirectory();
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function withOpenedNoFollowDirectory(open, failure, operation) {
|
|
120
|
+
let descriptor;
|
|
121
|
+
try {
|
|
122
|
+
descriptor = open();
|
|
123
|
+
const opened = fs_1.default.fstatSync(descriptor, { bigint: true });
|
|
124
|
+
if (!opened.isDirectory())
|
|
125
|
+
throw failure();
|
|
126
|
+
return operation(`/proc/self/fd/${descriptor}`);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
throw failure();
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
if (descriptor !== undefined) {
|
|
133
|
+
try {
|
|
134
|
+
fs_1.default.closeSync(descriptor);
|
|
135
|
+
}
|
|
136
|
+
catch { /* best effort descriptor cleanup */ }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Bind a directory without following an observable symlink. Linux uses a
|
|
142
|
+
* descriptor path so ancestor renames cannot redirect work. Other platforms
|
|
143
|
+
* validate every component and its identity before and after the operation;
|
|
144
|
+
* Node exposes no portable openat equivalent, so any observable change fails
|
|
145
|
+
* closed rather than being accepted as a pathname race.
|
|
146
|
+
*/
|
|
147
|
+
function withNoFollowDirectory(directory, failure, operation) {
|
|
148
|
+
const noFollow = fs_1.default.constants.O_NOFOLLOW;
|
|
149
|
+
const directoryFlag = fs_1.default.constants.O_DIRECTORY;
|
|
150
|
+
if (!path_1.default.isAbsolute(directory))
|
|
151
|
+
throw failure();
|
|
152
|
+
if (typeof noFollow !== 'number' || typeof directoryFlag !== 'number' || !procfsDescriptorBridgeAvailable()) {
|
|
153
|
+
return withPortableValidatedDirectory(directory, failure, operation);
|
|
154
|
+
}
|
|
155
|
+
let descriptor;
|
|
156
|
+
try {
|
|
157
|
+
const parsed = path_1.default.parse(directory);
|
|
158
|
+
descriptor = fs_1.default.openSync(parsed.root, fs_1.default.constants.O_RDONLY | noFollow | directoryFlag);
|
|
159
|
+
const relative = path_1.default.relative(parsed.root, path_1.default.resolve(directory));
|
|
160
|
+
for (const component of relative.split(path_1.default.sep).filter(Boolean)) {
|
|
161
|
+
const next = fs_1.default.openSync(`/proc/self/fd/${descriptor}/${component}`, fs_1.default.constants.O_RDONLY | noFollow | directoryFlag);
|
|
162
|
+
fs_1.default.closeSync(descriptor);
|
|
163
|
+
descriptor = next;
|
|
164
|
+
}
|
|
165
|
+
const finalDescriptor = descriptor;
|
|
166
|
+
descriptor = undefined;
|
|
167
|
+
return withOpenedNoFollowDirectory(() => finalDescriptor, failure, operation);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
throw failure();
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
if (descriptor !== undefined) {
|
|
174
|
+
try {
|
|
175
|
+
fs_1.default.closeSync(descriptor);
|
|
176
|
+
}
|
|
177
|
+
catch { /* already closed during a failed handoff */ }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
8
181
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
182
|
+
* Run a filesystem mutation only through a descriptor-bound directory path.
|
|
183
|
+
* Identity snapshots are sufficient for bounded reads, but cannot make a
|
|
184
|
+
* pathname write safe: a parent can be replaced after the snapshot and before
|
|
185
|
+
* `mkdtemp`, staging, or publication. Platforms without the Linux descriptor
|
|
186
|
+
* bridge therefore reject before the mutation callback begins.
|
|
13
187
|
*/
|
|
188
|
+
function withDescriptorBoundWriteDirectory(directory, failure, operation) {
|
|
189
|
+
const noFollow = fs_1.default.constants.O_NOFOLLOW;
|
|
190
|
+
const directoryFlag = fs_1.default.constants.O_DIRECTORY;
|
|
191
|
+
if (typeof noFollow !== 'number' || typeof directoryFlag !== 'number' || !procfsDescriptorBridgeAvailable())
|
|
192
|
+
throw failure();
|
|
193
|
+
return withNoFollowDirectory(directory, failure, operation);
|
|
194
|
+
}
|
|
195
|
+
/** Open one direct child from a previously bound directory without path re-resolution. */
|
|
196
|
+
function withNoFollowChildDirectory(parentDirectory, component, failure, operation) {
|
|
197
|
+
const noFollow = fs_1.default.constants.O_NOFOLLOW;
|
|
198
|
+
const directoryFlag = fs_1.default.constants.O_DIRECTORY;
|
|
199
|
+
if (!component || component === '.' || component === '..' || /[\\/]/.test(component))
|
|
200
|
+
throw failure();
|
|
201
|
+
if (typeof noFollow !== 'number' || typeof directoryFlag !== 'number' || !procfsDescriptorBridgeAvailable()) {
|
|
202
|
+
return withPortableValidatedDirectory(path_1.default.join(parentDirectory, component), failure, operation);
|
|
203
|
+
}
|
|
204
|
+
return withOpenedNoFollowDirectory(() => fs_1.default.openSync(path_1.default.join(parentDirectory, component), fs_1.default.constants.O_RDONLY | noFollow | directoryFlag), failure, operation);
|
|
205
|
+
}
|
|
206
|
+
/** Read the exact regular file that was previously inspected. */
|
|
14
207
|
function readInspectedBoundedFile(file, inspected, maxBytes, failure) {
|
|
208
|
+
// The JS descriptor bridge passes /proc/self/fd paths internally on Linux.
|
|
209
|
+
// They are already descriptor-bound; the native loader correctly rejects the
|
|
210
|
+
// procfs symlink representation, so retain this narrow internal path only.
|
|
211
|
+
const descriptorBoundInternalPath = process.platform === 'linux' && file.startsWith('/proc/self/fd/');
|
|
212
|
+
// The native boundary is mandatory in every shipped runtime. The
|
|
213
|
+
// filesystem race corpus opts into the portable path through a test-only
|
|
214
|
+
// seam; environment variables can never weaken production reads.
|
|
215
|
+
if (!secureFsForTest && !descriptorBoundInternalPath && !portableReadForTests) {
|
|
216
|
+
return readInspectedBoundedFileWithIdentity(file, inspected, maxBytes, failure).content;
|
|
217
|
+
}
|
|
218
|
+
// Test adapters observe the same boundary, then deliberately exercise the
|
|
219
|
+
// legacy race fixtures below. They are never selected by production code.
|
|
220
|
+
if (secureFsForTest)
|
|
221
|
+
secureFsForTest.readRegularFile(file, maxBytes);
|
|
15
222
|
const noFollow = fs_1.default.constants.O_NOFOLLOW;
|
|
223
|
+
const directorySnapshots = typeof noFollow === 'number' && procfsDescriptorBridgeAvailable()
|
|
224
|
+
? undefined
|
|
225
|
+
: snapshotDirectoryChain(path_1.default.dirname(file), () => failure('identity'));
|
|
16
226
|
let descriptor;
|
|
17
227
|
try {
|
|
18
228
|
descriptor = fs_1.default.openSync(file, fs_1.default.constants.O_RDONLY | (typeof noFollow === 'number' ? noFollow : 0));
|
|
@@ -30,7 +240,7 @@ function readInspectedBoundedFile(file, inspected, maxBytes, failure) {
|
|
|
30
240
|
}
|
|
31
241
|
if (!opened.isFile())
|
|
32
242
|
throw failure('regular');
|
|
33
|
-
if (opened
|
|
243
|
+
if (!sameIdentity(opened, inspected))
|
|
34
244
|
throw failure('identity');
|
|
35
245
|
if (opened.size !== inspected.size)
|
|
36
246
|
throw failure('size');
|
|
@@ -43,9 +253,34 @@ function readInspectedBoundedFile(file, inspected, maxBytes, failure) {
|
|
|
43
253
|
throw failure('limit');
|
|
44
254
|
if (BigInt(count) !== opened.size)
|
|
45
255
|
throw failure('size');
|
|
256
|
+
const after = fs_1.default.fstatSync(descriptor, { bigint: true });
|
|
257
|
+
if (!after.isFile() || !sameIdentity(after, opened))
|
|
258
|
+
throw failure('identity');
|
|
259
|
+
if (after.size !== opened.size)
|
|
260
|
+
throw failure('size');
|
|
261
|
+
if (directorySnapshots)
|
|
262
|
+
assertDirectoryChainUnchanged(directorySnapshots, () => failure('identity'));
|
|
46
263
|
return buffer.subarray(0, count);
|
|
47
264
|
}
|
|
48
265
|
finally {
|
|
49
266
|
fs_1.default.closeSync(descriptor);
|
|
50
267
|
}
|
|
51
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Preserve the opaque identity minted by the same native handle that supplied
|
|
271
|
+
* the bytes. Replacement callers must pass this observation back unchanged.
|
|
272
|
+
*/
|
|
273
|
+
function readInspectedBoundedFileWithIdentity(file, inspected, maxBytes, failure) {
|
|
274
|
+
if (process.platform === 'linux' && file.startsWith('/proc/self/fd/'))
|
|
275
|
+
throw failure('identity');
|
|
276
|
+
let observed;
|
|
277
|
+
try {
|
|
278
|
+
observed = activeSecureFs().readRegularFile(file, maxBytes);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
throw failure('open');
|
|
282
|
+
}
|
|
283
|
+
if (BigInt(observed.bytes.length) !== inspected.size)
|
|
284
|
+
throw failure('size');
|
|
285
|
+
return Object.freeze({ content: observed.bytes, identity: observed.identity });
|
|
286
|
+
}
|
|
@@ -39,8 +39,8 @@ async function runCoverage(cwd, dependencies = {}, options = {}) {
|
|
|
39
39
|
empirical: empirical(deps, input.projectRoot, {}, min, 'unavailable') };
|
|
40
40
|
}
|
|
41
41
|
let live;
|
|
42
|
-
if (input.manifest.kind
|
|
43
|
-
live = (await deps.resolveLive(input.projectRoot, input.pack, input.
|
|
42
|
+
if (input.manifest.kind !== 'legacy') {
|
|
43
|
+
live = (await deps.resolveLive(input.projectRoot, input.pack, input.registryRoot)).sensors;
|
|
44
44
|
}
|
|
45
45
|
else {
|
|
46
46
|
live = Object.fromEntries(Object.keys(input.manifest.pack.sensors).map((name) => [name, (0, manifest_1.legacyCompatibility)('legacy manifest without schemaVersion')]));
|