agentic-workflow-manager 9.18.0 → 9.19.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/plan/index.js +1 -1
- package/dist/src/commands/sensors/read-only-observation.js +305 -0
- package/dist/src/commands/sensors/run.js +23 -60
- package/dist/src/core/admission/index.js +11 -1
- package/dist/src/core/currentness/check.js +5 -2
- package/dist/tests/commands/sensors/read-only-observation.test.js +537 -0
- package/dist/tests/commands/sensors/run-read-only.test.js +151 -0
- package/dist/tests/core/admission/index.test.js +60 -0
- package/dist/tests/core/currentness/check-insteadof.test.js +120 -0
- package/dist/tests/core/currentness/check.test.js +75 -14
- package/dist/tests/structural/cloud-admission-docs-contract.test.js +54 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -283,7 +283,7 @@ function registerPlanCommand(program, deps = { validatePlanFile: validate_1.vali
|
|
|
283
283
|
.requiredOption('--cwd <path>', 'repository root for plan containment and source resolution')
|
|
284
284
|
.option('--execution-mode <mode>', 'explicit override: interactivo or desatendido')
|
|
285
285
|
.option('--require-current', 'require authoritative consumed-contract currentness')
|
|
286
|
-
.option('--verify-sensors', 'require an empirical sensor pass')
|
|
286
|
+
.option('--verify-sensors', 'require an empirical sensor pass; read-only observation skips git-ignored build output (see .awm/admission.json)')
|
|
287
287
|
.option('--runtime-kind <kind>', 'routing runtime kind for compact v2')
|
|
288
288
|
.option('--runtime-version <version>', 'routing runtime version for compact v2')
|
|
289
289
|
.option('--account-scope-digest <sha>', 'routing account scope digest for compact v2')
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DEFAULT_EXCLUSION_NAMES = exports.READ_ONLY_MAX_BYTES = exports.READ_ONLY_MAX_ENTRIES = void 0;
|
|
7
|
+
exports.loadAdmissionConfig = loadAdmissionConfig;
|
|
8
|
+
exports.createExclusionPolicy = createExclusionPolicy;
|
|
9
|
+
exports.gitVerifier = gitVerifier;
|
|
10
|
+
exports.filesystemSnapshot = filesystemSnapshot;
|
|
11
|
+
exports.snapshotUnavailableRemedy = snapshotUnavailableRemedy;
|
|
12
|
+
exports.admissionConfigRemedy = admissionConfigRemedy;
|
|
13
|
+
const fs_1 = __importDefault(require("fs"));
|
|
14
|
+
const path_1 = __importDefault(require("path"));
|
|
15
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
16
|
+
const child_process_1 = require("child_process");
|
|
17
|
+
/**
|
|
18
|
+
* Admission's read-only observation (issue #215). `runSensors({ readOnly })` hashes
|
|
19
|
+
* the project tree before and after the sensors run; any difference means the
|
|
20
|
+
* evidence is not read-only. Generated output (node_modules, .next, …) can push that
|
|
21
|
+
* walk past its limits, so a directory that git ignores and that holds no tracked
|
|
22
|
+
* file may be skipped. Source files, `.gitignore`, and `.awm/` stay observed.
|
|
23
|
+
*/
|
|
24
|
+
exports.READ_ONLY_MAX_ENTRIES = 250_000;
|
|
25
|
+
exports.READ_ONLY_MAX_BYTES = 1024 * 1024 * 1024;
|
|
26
|
+
exports.DEFAULT_EXCLUSION_NAMES = Object.freeze(['node_modules', '.next', '.turbo', 'dist', 'build', 'coverage', '.venv', 'target']);
|
|
27
|
+
const ADMISSION_CONFIG_LABEL = '.awm/admission.json';
|
|
28
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
29
|
+
const GIT_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
30
|
+
const MAX_FIELD_LABEL = 64;
|
|
31
|
+
const MAX_CONFIG_BYTES = 65_536;
|
|
32
|
+
// Control characters (C0, DEL, C1), bidi controls (marks, embeddings, isolates) and line/paragraph separators.
|
|
33
|
+
const UNSAFE_LABEL_CHARS = /[\p{Cc}\p{Bidi_Control}\u2028\u2029]/gu;
|
|
34
|
+
// Variables that relocate the repository, index or object store: inherited ones would
|
|
35
|
+
// make git answer for a different repository than `projectRoot` (fail-open).
|
|
36
|
+
const GIT_LOCATION_VARIABLES = new Set([
|
|
37
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
|
|
38
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_COMMON_DIR', 'GIT_NAMESPACE', 'GIT_PREFIX',
|
|
39
|
+
]);
|
|
40
|
+
const RESERVED_NAMES = new Set(['.git', '.awm']);
|
|
41
|
+
const DEFAULT_LIMITS = { maxEntries: exports.READ_ONLY_MAX_ENTRIES, maxBytes: exports.READ_ONLY_MAX_BYTES };
|
|
42
|
+
function requireRoot(value, label) {
|
|
43
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
44
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
function isRecord(value) {
|
|
48
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
/** Unknown keys come from the project file: bound them and neutralize control characters. */
|
|
51
|
+
function fieldLabel(key) {
|
|
52
|
+
const clean = key.replace(UNSAFE_LABEL_CHARS, '?');
|
|
53
|
+
return clean.length <= MAX_FIELD_LABEL ? clean : `${clean.slice(0, MAX_FIELD_LABEL - 1)}…`;
|
|
54
|
+
}
|
|
55
|
+
function defaultRules() {
|
|
56
|
+
return exports.DEFAULT_EXCLUSION_NAMES.map(name => ({ kind: 'name', name }));
|
|
57
|
+
}
|
|
58
|
+
/** Exact, case-insensitive match: `my.awm` and `.gitignore-dir` are not reserved. */
|
|
59
|
+
function isReservedSegment(segment) {
|
|
60
|
+
return RESERVED_NAMES.has(segment.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
function entryProblem(entry) {
|
|
63
|
+
if (entry === '')
|
|
64
|
+
return 'must not be empty';
|
|
65
|
+
if (/[\0\r\n\\*?[]/.test(entry))
|
|
66
|
+
return 'must not contain a backslash, wildcard, NUL or line break';
|
|
67
|
+
if (entry.startsWith('/') || /^[A-Za-z]:/.test(entry))
|
|
68
|
+
return 'must be relative to the project root';
|
|
69
|
+
if (entry.endsWith('/'))
|
|
70
|
+
return 'must not end with "/"';
|
|
71
|
+
const segments = entry.split('/');
|
|
72
|
+
if (segments.some(segment => segment === '' || segment === '.' || segment === '..'))
|
|
73
|
+
return 'must not contain an empty, "." or ".." segment';
|
|
74
|
+
if (segments.some(isReservedSegment))
|
|
75
|
+
return 'must not exclude .git or .awm';
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/** Strict parser for the optional `.awm/admission.json`; absent means AWM defaults. */
|
|
79
|
+
function loadAdmissionConfig(projectRoot) {
|
|
80
|
+
const root = requireRoot(projectRoot, 'admission config projectRoot');
|
|
81
|
+
const invalid = (field, message) => ({ ok: false, field, message });
|
|
82
|
+
const file = path_1.default.join(root, '.awm', 'admission.json');
|
|
83
|
+
// The file is repo-controlled: inspect it before reading so a link, device or huge
|
|
84
|
+
// file can never be followed or slurped into memory.
|
|
85
|
+
let stat;
|
|
86
|
+
try {
|
|
87
|
+
stat = fs_1.default.lstatSync(file);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code === 'ENOENT')
|
|
91
|
+
return { ok: true, config: { defaults: true, rules: defaultRules() } };
|
|
92
|
+
return invalid('(file)', 'could not be read');
|
|
93
|
+
}
|
|
94
|
+
if (!stat.isFile())
|
|
95
|
+
return invalid('(file)', 'must be a regular file, not a link or device');
|
|
96
|
+
if (stat.size > MAX_CONFIG_BYTES)
|
|
97
|
+
return invalid('(file)', `is larger than ${MAX_CONFIG_BYTES} bytes`);
|
|
98
|
+
let raw;
|
|
99
|
+
try {
|
|
100
|
+
raw = fs_1.default.readFileSync(file, 'utf8');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return invalid('(file)', 'could not be read');
|
|
104
|
+
}
|
|
105
|
+
let value;
|
|
106
|
+
try {
|
|
107
|
+
value = JSON.parse(raw);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return invalid('(file)', 'is not valid JSON');
|
|
111
|
+
}
|
|
112
|
+
if (!isRecord(value))
|
|
113
|
+
return invalid('(root)', 'must be a JSON object');
|
|
114
|
+
const unknownRoot = Object.keys(value).find(key => key !== 'schemaVersion' && key !== 'readOnlyObservation');
|
|
115
|
+
if (unknownRoot !== undefined)
|
|
116
|
+
return invalid(fieldLabel(unknownRoot), 'is not a known field');
|
|
117
|
+
if (value.schemaVersion !== 1)
|
|
118
|
+
return invalid('schemaVersion', 'must be 1');
|
|
119
|
+
const observation = 'readOnlyObservation' in value ? value.readOnlyObservation : {};
|
|
120
|
+
if (!isRecord(observation))
|
|
121
|
+
return invalid('readOnlyObservation', 'must be an object');
|
|
122
|
+
const unknownObservation = Object.keys(observation).find(key => key !== 'defaults' && key !== 'exclude');
|
|
123
|
+
if (unknownObservation !== undefined)
|
|
124
|
+
return invalid(`readOnlyObservation.${fieldLabel(unknownObservation)}`, 'is not a known field');
|
|
125
|
+
const defaults = 'defaults' in observation ? observation.defaults : true;
|
|
126
|
+
if (typeof defaults !== 'boolean')
|
|
127
|
+
return invalid('readOnlyObservation.defaults', 'must be a boolean');
|
|
128
|
+
const exclude = 'exclude' in observation ? observation.exclude : [];
|
|
129
|
+
if (!Array.isArray(exclude))
|
|
130
|
+
return invalid('readOnlyObservation.exclude', 'must be an array of strings');
|
|
131
|
+
const rules = defaults ? defaultRules() : [];
|
|
132
|
+
for (const [index, entry] of exclude.entries()) {
|
|
133
|
+
const field = `readOnlyObservation.exclude[${index}]`;
|
|
134
|
+
if (typeof entry !== 'string')
|
|
135
|
+
return invalid(field, 'must be a string');
|
|
136
|
+
const problem = entryProblem(entry);
|
|
137
|
+
if (problem)
|
|
138
|
+
return invalid(field, problem);
|
|
139
|
+
rules.push(entry.includes('/') ? { kind: 'path', path: entry } : { kind: 'name', name: entry });
|
|
140
|
+
}
|
|
141
|
+
return { ok: true, config: { defaults, rules } };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Rule matching plus cached verdicts. The same policy instance serves the before and
|
|
145
|
+
* after snapshots, so a candidate is verified once and both passes agree.
|
|
146
|
+
*/
|
|
147
|
+
function createExclusionPolicy(config, verifier) {
|
|
148
|
+
if (!config || !Array.isArray(config.rules) || typeof config.defaults !== 'boolean')
|
|
149
|
+
throw new Error('exclusion policy requires a validated admission config');
|
|
150
|
+
if (typeof verifier !== 'function')
|
|
151
|
+
throw new Error('exclusion policy requires a candidate verifier');
|
|
152
|
+
const names = new Set(config.rules.flatMap(rule => (rule.kind === 'name' ? [rule.name] : [])));
|
|
153
|
+
const paths = new Set(config.rules.flatMap(rule => (rule.kind === 'path' ? [rule.path] : [])));
|
|
154
|
+
const verdicts = new Map();
|
|
155
|
+
return {
|
|
156
|
+
isExcluded(relativePath, name) {
|
|
157
|
+
// .git and .awm are never excluded, at any depth, nor anything inside them (B-R5).
|
|
158
|
+
if (relativePath.split('/').some(isReservedSegment))
|
|
159
|
+
return false;
|
|
160
|
+
if (!names.has(name) && !paths.has(relativePath))
|
|
161
|
+
return false;
|
|
162
|
+
let verdict = verdicts.get(relativePath);
|
|
163
|
+
if (verdict === undefined) {
|
|
164
|
+
verdict = verifier(relativePath);
|
|
165
|
+
verdicts.set(relativePath, verdict);
|
|
166
|
+
}
|
|
167
|
+
return verdict === 'excluded';
|
|
168
|
+
},
|
|
169
|
+
evidence() {
|
|
170
|
+
const excluded = [];
|
|
171
|
+
const rejected = [];
|
|
172
|
+
const sorted = [...verdicts.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
173
|
+
for (const [candidate, verdict] of sorted) {
|
|
174
|
+
if (verdict === 'excluded')
|
|
175
|
+
excluded.push(candidate);
|
|
176
|
+
else
|
|
177
|
+
rejected.push({ path: candidate, reason: verdict });
|
|
178
|
+
}
|
|
179
|
+
return { defaults: config.defaults, excluded, rejected };
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** A copy of `env` without repository-location variables (case-insensitive: Windows). */
|
|
184
|
+
function scrubGitLocation(env) {
|
|
185
|
+
const scrubbed = {};
|
|
186
|
+
for (const [key, value] of Object.entries(env)) {
|
|
187
|
+
if (!GIT_LOCATION_VARIABLES.has(key.toUpperCase()))
|
|
188
|
+
scrubbed[key] = value;
|
|
189
|
+
}
|
|
190
|
+
return scrubbed;
|
|
191
|
+
}
|
|
192
|
+
/** Git decides: no tracked file inside (checked first) and ignored. Any doubt observes. */
|
|
193
|
+
function gitVerifier(projectRoot, env = process.env, spawn = child_process_1.spawnSync) {
|
|
194
|
+
const cwd = requireRoot(projectRoot, 'git verifier projectRoot');
|
|
195
|
+
const childEnv = scrubGitLocation(env);
|
|
196
|
+
const git = (args) => spawn('git', args, {
|
|
197
|
+
cwd,
|
|
198
|
+
env: childEnv,
|
|
199
|
+
encoding: 'utf8',
|
|
200
|
+
timeout: GIT_TIMEOUT_MS,
|
|
201
|
+
maxBuffer: GIT_MAX_OUTPUT_BYTES,
|
|
202
|
+
windowsHide: true,
|
|
203
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
204
|
+
});
|
|
205
|
+
return relativePath => {
|
|
206
|
+
// Git reads magic prefixes (":(top)", ":/") even after "--"; a leading "./"
|
|
207
|
+
// makes the candidate an ordinary relative pathspec, so a directory that is
|
|
208
|
+
// literally named like magic is never mistaken for a different path.
|
|
209
|
+
const pathspec = `./${relativePath}`;
|
|
210
|
+
// Tracked first: git already reports a directory holding a force-added file
|
|
211
|
+
// as not ignored, which would hide the more precise reason.
|
|
212
|
+
const tracked = git(['ls-files', '-z', '--', pathspec]);
|
|
213
|
+
if (tracked.error?.code === 'ENOBUFS')
|
|
214
|
+
return 'contains-tracked';
|
|
215
|
+
if (tracked.error || tracked.status !== 0)
|
|
216
|
+
return 'git-unavailable';
|
|
217
|
+
if (tracked.stdout !== '')
|
|
218
|
+
return 'contains-tracked';
|
|
219
|
+
const ignored = git(['check-ignore', '-q', '--', pathspec]);
|
|
220
|
+
if (ignored.error)
|
|
221
|
+
return 'git-unavailable';
|
|
222
|
+
if (ignored.status === 0)
|
|
223
|
+
return 'excluded';
|
|
224
|
+
return ignored.status === 1 ? 'not-ignored' : 'git-unavailable';
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
class SnapshotLimitError extends Error {
|
|
228
|
+
failure;
|
|
229
|
+
constructor(failure) {
|
|
230
|
+
super(failure);
|
|
231
|
+
this.failure = failure;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* A git status only sees tracked and non-ignored paths, while a sensor can mutate
|
|
236
|
+
* ignored runtime files (notably `.awm/`) just as easily, so the actual tree is
|
|
237
|
+
* hashed. The walk is bounded: inability to observe the whole declared project means
|
|
238
|
+
* no admission certificate. Excluded directories contribute nothing; observed
|
|
239
|
+
* directories are hashed without their size, which changes as entries come and go
|
|
240
|
+
* and is already covered by their children.
|
|
241
|
+
*/
|
|
242
|
+
function filesystemSnapshot(root, policy, limits = DEFAULT_LIMITS) {
|
|
243
|
+
requireRoot(root, 'snapshot root');
|
|
244
|
+
if (!Number.isSafeInteger(limits.maxEntries) || limits.maxEntries < 1 || !Number.isSafeInteger(limits.maxBytes) || limits.maxBytes < 1) {
|
|
245
|
+
throw new Error('snapshot limits must be positive integers');
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const digest = crypto_1.default.createHash('sha256');
|
|
249
|
+
let entriesSeen = 0;
|
|
250
|
+
let bytesSeen = 0;
|
|
251
|
+
const visit = (directory, relative) => {
|
|
252
|
+
const entries = fs_1.default.readdirSync(directory, { withFileTypes: true })
|
|
253
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
// Git's internal bookkeeping changes as a consequence of normal
|
|
256
|
+
// observation, not project sensor execution.
|
|
257
|
+
if (relative === '' && entry.name === '.git')
|
|
258
|
+
continue;
|
|
259
|
+
const child = path_1.default.join(directory, entry.name);
|
|
260
|
+
const childRelative = relative === '' ? entry.name : `${relative}/${entry.name}`;
|
|
261
|
+
const stat = fs_1.default.lstatSync(child);
|
|
262
|
+
if (stat.isDirectory() && policy?.isExcluded(childRelative, entry.name))
|
|
263
|
+
continue;
|
|
264
|
+
if (++entriesSeen > limits.maxEntries)
|
|
265
|
+
throw new SnapshotLimitError('limit-entries');
|
|
266
|
+
if (stat.isDirectory()) {
|
|
267
|
+
digest.update(`${childRelative}\0${stat.mode}\0directory\0`);
|
|
268
|
+
visit(child, childRelative);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
digest.update(`${childRelative}\0${stat.mode}\0${stat.size}\0`);
|
|
272
|
+
if (stat.isFile()) {
|
|
273
|
+
if ((bytesSeen += stat.size) > limits.maxBytes)
|
|
274
|
+
throw new SnapshotLimitError('limit-bytes');
|
|
275
|
+
digest.update('file\0');
|
|
276
|
+
digest.update(fs_1.default.readFileSync(child));
|
|
277
|
+
}
|
|
278
|
+
else if (stat.isSymbolicLink()) {
|
|
279
|
+
digest.update(`symlink\0${fs_1.default.readlinkSync(child)}\0`);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
digest.update(`special\0${stat.rdev}\0`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
visit(root, '');
|
|
287
|
+
return { ok: true, digest: digest.digest('hex') };
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
// Every unexpected error (unreadable entry, vanished file, a throwing injected
|
|
291
|
+
// policy) is deliberately reported as 'unreadable': a tree that cannot be fully
|
|
292
|
+
// observed must fail closed instead of yielding a digest.
|
|
293
|
+
return { ok: false, failure: error instanceof SnapshotLimitError ? error.failure : 'unreadable' };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function snapshotUnavailableRemedy(failure) {
|
|
297
|
+
if (failure === 'unreadable')
|
|
298
|
+
return 'run admission from a readable git worktree';
|
|
299
|
+
const limit = failure === 'limit-bytes' ? `${exports.READ_ONLY_MAX_BYTES / 1024 ** 3} GiB of observed file content` : `${exports.READ_ONLY_MAX_ENTRIES} observed entries`;
|
|
300
|
+
return `the project tree exceeds the read-only observation limit of ${limit}; git-ignore heavy generated directories `
|
|
301
|
+
+ `(ignored ${exports.DEFAULT_EXCLUSION_NAMES.join(', ')} are skipped by default) or declare them in ${ADMISSION_CONFIG_LABEL} readOnlyObservation.exclude`;
|
|
302
|
+
}
|
|
303
|
+
function admissionConfigRemedy(problem) {
|
|
304
|
+
return `repair ${ADMISSION_CONFIG_LABEL}: ${problem.field} ${problem.message}`;
|
|
305
|
+
}
|
|
@@ -8,10 +8,8 @@ exports.findManifestDir = findManifestDir;
|
|
|
8
8
|
exports.detectPackDrift = detectPackDrift;
|
|
9
9
|
exports.resolveConcurrency = resolveConcurrency;
|
|
10
10
|
exports.runSensors = runSensors;
|
|
11
|
-
const fs_1 = __importDefault(require("fs"));
|
|
12
11
|
const os_1 = __importDefault(require("os"));
|
|
13
12
|
const path_1 = __importDefault(require("path"));
|
|
14
|
-
const crypto_1 = __importDefault(require("crypto"));
|
|
15
13
|
const baseline_1 = require("./baseline");
|
|
16
14
|
const result_1 = require("./result");
|
|
17
15
|
Object.defineProperty(exports, "applyBaseline", { enumerable: true, get: function () { return result_1.applyBaseline; } });
|
|
@@ -28,64 +26,12 @@ const registries_1 = require("../../core/registries");
|
|
|
28
26
|
const prepare_1 = require("./prepare");
|
|
29
27
|
const verdict_1 = require("./verdict");
|
|
30
28
|
const project_1 = require("./project");
|
|
29
|
+
const read_only_observation_1 = require("./read-only-observation");
|
|
31
30
|
// Sensor JSON output can be several MB on large repos (e.g. `eslint --format json`
|
|
32
31
|
// with thousands of findings). A 1MB cap killed the child with SIGTERM when
|
|
33
32
|
// exceeded — which previously surfaced as a false "timeout".
|
|
34
33
|
/** Hard ceiling on parallel sensors: past this, they only contend for the same cores. */
|
|
35
34
|
const MAX_CONCURRENCY = 4;
|
|
36
|
-
const READ_ONLY_MAX_ENTRIES = 250_000;
|
|
37
|
-
const READ_ONLY_MAX_BYTES = 1024 * 1024 * 1024;
|
|
38
|
-
/**
|
|
39
|
-
* A git status only sees tracked and non-ignored paths, while a sensor can
|
|
40
|
-
* mutate ignored runtime files (notably `.awm/`) just as easily. Hash the
|
|
41
|
-
* actual project tree instead. The scan is deliberately bounded: inability to
|
|
42
|
-
* observe the whole declared project means no admission certificate.
|
|
43
|
-
*/
|
|
44
|
-
function filesystemSnapshot(root) {
|
|
45
|
-
try {
|
|
46
|
-
const digest = crypto_1.default.createHash('sha256');
|
|
47
|
-
let entriesSeen = 0;
|
|
48
|
-
let bytesSeen = 0;
|
|
49
|
-
const visit = (directory, relative) => {
|
|
50
|
-
const entries = fs_1.default.readdirSync(directory, { withFileTypes: true })
|
|
51
|
-
.sort((left, right) => left.name.localeCompare(right.name));
|
|
52
|
-
for (const entry of entries) {
|
|
53
|
-
// Git's internal bookkeeping changes as a consequence of normal
|
|
54
|
-
// observation, not project sensor execution. Everything else,
|
|
55
|
-
// including ignored/untracked `.awm`, is evidence-relevant.
|
|
56
|
-
if (relative === '' && entry.name === '.git')
|
|
57
|
-
continue;
|
|
58
|
-
if (++entriesSeen > READ_ONLY_MAX_ENTRIES)
|
|
59
|
-
throw new Error('snapshot-entry-limit');
|
|
60
|
-
const child = path_1.default.join(directory, entry.name);
|
|
61
|
-
const childRelative = relative === '' ? entry.name : `${relative}/${entry.name}`;
|
|
62
|
-
const stat = fs_1.default.lstatSync(child);
|
|
63
|
-
digest.update(`${childRelative}\0${stat.mode}\0${stat.size}\0`);
|
|
64
|
-
if (stat.isDirectory()) {
|
|
65
|
-
digest.update('directory\0');
|
|
66
|
-
visit(child, childRelative);
|
|
67
|
-
}
|
|
68
|
-
else if (stat.isFile()) {
|
|
69
|
-
if ((bytesSeen += stat.size) > READ_ONLY_MAX_BYTES)
|
|
70
|
-
throw new Error('snapshot-byte-limit');
|
|
71
|
-
digest.update('file\0');
|
|
72
|
-
digest.update(fs_1.default.readFileSync(child));
|
|
73
|
-
}
|
|
74
|
-
else if (stat.isSymbolicLink()) {
|
|
75
|
-
digest.update(`symlink\0${fs_1.default.readlinkSync(child)}\0`);
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
digest.update(`special\0${stat.rdev}\0`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
visit(root, '');
|
|
83
|
-
return digest.digest('hex');
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
35
|
async function resolveLiveFromSource(cwd, source, packSelection) {
|
|
90
36
|
try {
|
|
91
37
|
const parsed = (0, contract_1.parseSensorPack)(JSON.parse(source.content), source.path);
|
|
@@ -213,9 +159,20 @@ async function runSensors(opts = {}) {
|
|
|
213
159
|
configured = project;
|
|
214
160
|
}
|
|
215
161
|
const manifestDir = configured.projectRoot;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
162
|
+
// Admission-only: `.awm/admission.json` is never read for ordinary runs.
|
|
163
|
+
let readOnlyPolicy;
|
|
164
|
+
let beforeReadOnly;
|
|
165
|
+
if (opts.readOnly) {
|
|
166
|
+
const admissionConfig = (0, read_only_observation_1.loadAdmissionConfig)(manifestDir);
|
|
167
|
+
if (!admissionConfig.ok) {
|
|
168
|
+
return { sensors: [], overall: 'not_certified', projectRoot: configured.projectRoot, manifestPath: configured.manifestPath, mode: 'invalid', reason: 'admission-config-invalid', remedy: (0, read_only_observation_1.admissionConfigRemedy)(admissionConfig) };
|
|
169
|
+
}
|
|
170
|
+
readOnlyPolicy = (0, read_only_observation_1.createExclusionPolicy)(admissionConfig.config, (0, read_only_observation_1.gitVerifier)(manifestDir));
|
|
171
|
+
const before = (0, read_only_observation_1.filesystemSnapshot)(manifestDir, readOnlyPolicy);
|
|
172
|
+
if (!before.ok) {
|
|
173
|
+
return { sensors: [], overall: 'not_certified', projectRoot: configured.projectRoot, manifestPath: configured.manifestPath, mode: 'invalid', reason: 'read-only-observation-unavailable', remedy: (0, read_only_observation_1.snapshotUnavailableRemedy)(before.failure), readOnlyObservation: readOnlyPolicy.evidence() };
|
|
174
|
+
}
|
|
175
|
+
beforeReadOnly = before.digest;
|
|
219
176
|
}
|
|
220
177
|
const parsed = configured.manifest;
|
|
221
178
|
const authority = { projectRoot: configured.projectRoot, manifestPath: configured.manifestPath };
|
|
@@ -382,8 +339,14 @@ async function runSensors(opts = {}) {
|
|
|
382
339
|
...(drift?.drift ? { packDrift: drift.drift } : {}),
|
|
383
340
|
...(changed ? { changedScope: { files: changed.files.length, ...(changed.error ? { error: changed.error } : {}) } } : {}),
|
|
384
341
|
};
|
|
385
|
-
if (
|
|
386
|
-
|
|
342
|
+
if (readOnlyPolicy) {
|
|
343
|
+
// Same policy instance: cached verdicts keep both passes consistent.
|
|
344
|
+
const after = (0, read_only_observation_1.filesystemSnapshot)(manifestDir, readOnlyPolicy);
|
|
345
|
+
const readOnlyObservation = readOnlyPolicy.evidence();
|
|
346
|
+
if (!after.ok || after.digest !== beforeReadOnly) {
|
|
347
|
+
return { ...output, overall: 'not_certified', reason: 'read-only-mutation-detected', remedy: 'sensor execution changed the worktree; repair it before using this evidence for admission', readOnlyObservation };
|
|
348
|
+
}
|
|
349
|
+
return { ...output, readOnlyObservation };
|
|
387
350
|
}
|
|
388
351
|
return output;
|
|
389
352
|
}
|
|
@@ -101,6 +101,16 @@ function currentness(report, consumedRegistryComponents) {
|
|
|
101
101
|
return { status, cli, diagnostics: [diagnostic('ADMISSION_CURRENTNESS_BLOCKED', `Consumed contract currentness is ${status}: ${names}.`)] };
|
|
102
102
|
}
|
|
103
103
|
function sensorVerdict(report) { return report.overall === 'not_certified' ? 'not-certified' : report.overall === 'pass' ? 'pass' : 'fail'; }
|
|
104
|
+
/** A not-certified verdict alone does not say what to fix; carry the run's own cause. */
|
|
105
|
+
function sensorBlockedMessage(report, verdict) {
|
|
106
|
+
// The diagnostic code must stay the message's only ADMISSION_ token (recovery classification counts them); echoed text may carry user-chosen ones.
|
|
107
|
+
const plain = (text) => text.trim().replace(/ADMISSION_/g, 'ADMISSION-');
|
|
108
|
+
const reason = verdict === 'not-certified' && typeof report.reason === 'string' ? plain(report.reason) : '';
|
|
109
|
+
if (!reason)
|
|
110
|
+
return `Sensor verdict is ${verdict}.`;
|
|
111
|
+
const remedy = typeof report.remedy === 'string' ? plain(report.remedy).replace(/\.+$/, '') : '';
|
|
112
|
+
return remedy ? `Sensor verdict is ${verdict} (${reason}): ${remedy}.` : `Sensor verdict is ${verdict} (${reason}).`;
|
|
113
|
+
}
|
|
104
114
|
function journalStatus(input, plan) {
|
|
105
115
|
if (input.journalCorrupt)
|
|
106
116
|
return 'corrupt';
|
|
@@ -237,7 +247,7 @@ async function admitPlan(input) {
|
|
|
237
247
|
const sensors = sensorVerdict(input.sensors);
|
|
238
248
|
const registryOptOut = sensors === 'not-certified' && input.sensorClosurePolicy === 'r8-registry-opt-out' && input.sensors.mode === 'opt-out';
|
|
239
249
|
if (sensors !== 'pass' && !registryOptOut)
|
|
240
|
-
return blocked(input, [diagnostic('ADMISSION_SENSORS_BLOCKED',
|
|
250
|
+
return blocked(input, [diagnostic('ADMISSION_SENSORS_BLOCKED', sensorBlockedMessage(input.sensors, sensors))], { planDigest: plan.planDigest, provider, executionMode: mode, currentness: input.requireCurrent ? 'current' : 'not-checked', cliCurrentness, sensors });
|
|
241
251
|
sensorsReported = sensors;
|
|
242
252
|
if (registryOptOut)
|
|
243
253
|
sensorEvidence = { sensorEvidence: 'r8-registry-opt-out' };
|
|
@@ -162,12 +162,15 @@ async function registryComponent(registry, git, prefs, checkedAt) {
|
|
|
162
162
|
if (!authorizedSource)
|
|
163
163
|
return fail('Configured registry remote is not an authoritative HTTPS or SSH source.', 'Configure an authoritative registry remote and rerun strict preflight.');
|
|
164
164
|
const [originResult, headResult, tagResult, remoteResult] = await Promise.allSettled([
|
|
165
|
-
|
|
165
|
+
// The literal stored value of THIS checkout: `git remote get-url` applies url.*.insteadOf, and
|
|
166
|
+
// cloud environments (AWM's own D-7 recipe) rewrite to a token-bearing URL; without
|
|
167
|
+
// `--local`, a global/system `remote.origin.url` could stand in for a missing origin.
|
|
168
|
+
deadline(git(registry.contentRoot, ['config', '--local', '--get', 'remote.origin.url'], { timeoutMs: TIMEOUT_MS, maxOutputBytes: MAX_OUTPUT_BYTES })),
|
|
166
169
|
deadline(git(registry.contentRoot, ['rev-parse', 'HEAD'], { timeoutMs: TIMEOUT_MS, maxOutputBytes: MAX_OUTPUT_BYTES })),
|
|
167
170
|
deadline(git(registry.contentRoot, ['describe', '--tags', '--exact-match', 'HEAD'], { timeoutMs: TIMEOUT_MS, maxOutputBytes: MAX_OUTPUT_BYTES })),
|
|
168
171
|
deadline(git(registry.contentRoot, ['ls-remote', '--tags', registry.remote], { timeoutMs: TIMEOUT_MS, maxOutputBytes: MAX_OUTPUT_BYTES })),
|
|
169
172
|
]);
|
|
170
|
-
if (originResult.status === 'rejected')
|
|
173
|
+
if (originResult.status === 'rejected' || originResult.value.trim() === '')
|
|
171
174
|
return fail('Could not read local registry origin.', 'Inspect the local registry checkout and its origin remote.');
|
|
172
175
|
if (headResult.status === 'rejected')
|
|
173
176
|
return fail('Could not read local registry HEAD.', 'Inspect or repair the local registry checkout.');
|