agentic-workflow-manager 9.17.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/env/index.js +192 -0
- package/dist/src/commands/plan/index.js +1 -1
- package/dist/src/commands/registry/doctor.js +428 -0
- package/dist/src/commands/registry/index.js +52 -0
- package/dist/src/commands/registry/scaffold.js +140 -0
- package/dist/src/commands/sensors/read-only-observation.js +305 -0
- package/dist/src/commands/sensors/run.js +23 -60
- package/dist/src/commands/watch/index.js +3 -0
- package/dist/src/core/admission/index.js +11 -1
- package/dist/src/core/currentness/check.js +5 -2
- package/dist/src/core/environments/resolve.js +122 -0
- package/dist/src/core/environments/store.js +323 -0
- package/dist/src/core/facts/catalog.js +138 -0
- package/dist/src/core/facts/needs.js +57 -0
- package/dist/src/core/facts/types.js +21 -0
- package/dist/src/index.js +2 -0
- package/dist/tests/commands/env/index.test.js +261 -0
- package/dist/tests/commands/registry/doctor.test.js +283 -0
- package/dist/tests/commands/registry/scaffold.test.js +186 -0
- 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/commands/watch/reopened-196.test.js +9 -2
- 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/core/environments/resolve.test.js +347 -0
- package/dist/tests/core/environments/store.test.js +248 -0
- package/dist/tests/core/facts/catalog.test.js +179 -0
- package/dist/tests/core/facts/needs.test.js +88 -0
- package/dist/tests/structural/cloud-admission-docs-contract.test.js +54 -0
- package/dist/tests/structural/operator-environments-docs-contract.test.js +70 -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
|
@@ -8,6 +8,7 @@ exports.registerRegistryCommand = registerRegistryCommand;
|
|
|
8
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
9
|
const prompts_1 = require("@clack/prompts");
|
|
10
10
|
const picocolors_1 = __importDefault(require("picocolors"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
11
12
|
const registries_1 = require("../../core/registries");
|
|
12
13
|
const discovery_1 = require("../../core/discovery");
|
|
13
14
|
const bundles_1 = require("../../core/bundles");
|
|
@@ -16,10 +17,22 @@ const regenerate_1 = require("../../core/context/regenerate");
|
|
|
16
17
|
const add_1 = require("./add");
|
|
17
18
|
const remove_1 = require("./remove");
|
|
18
19
|
const status_1 = require("./status");
|
|
20
|
+
const doctor_1 = require("./doctor");
|
|
21
|
+
const scaffold_1 = require("./scaffold");
|
|
19
22
|
const config_1 = require("../../utils/config");
|
|
20
23
|
const profile_1 = require("../../core/profile");
|
|
21
24
|
const providers_1 = require("../../providers");
|
|
22
25
|
const install_bundles_1 = require("./install-bundles");
|
|
26
|
+
function resolveDoctorTarget(nameOrPath) {
|
|
27
|
+
if (!nameOrPath) {
|
|
28
|
+
throw new Error('Specify a registry name or path: awm registry doctor <name|path>');
|
|
29
|
+
}
|
|
30
|
+
const asPath = path_1.default.resolve(nameOrPath);
|
|
31
|
+
if (fs_1.default.existsSync(asPath) && fs_1.default.statSync(asPath).isDirectory()) {
|
|
32
|
+
return asPath;
|
|
33
|
+
}
|
|
34
|
+
return (0, registries_1.registryContentRoot)(nameOrPath);
|
|
35
|
+
}
|
|
23
36
|
function registerRegistryCommand(program) {
|
|
24
37
|
const reg = program.command('registry').description('manage additional content registries (team/personal)');
|
|
25
38
|
reg.command('add <remote>')
|
|
@@ -173,4 +186,43 @@ function registerRegistryCommand(program) {
|
|
|
173
186
|
}
|
|
174
187
|
(0, prompts_1.outro)(`✅ Registry ${picocolors_1.default.cyan(name)} removed.`);
|
|
175
188
|
});
|
|
189
|
+
reg.command('scaffold <dir>')
|
|
190
|
+
.description('create a minimal registry layout (handbook + facts) that passes doctor')
|
|
191
|
+
.action((dir) => {
|
|
192
|
+
(0, prompts_1.intro)(picocolors_1.default.bgCyan(picocolors_1.default.black(' AWM - Scaffold Registry ')));
|
|
193
|
+
const result = (0, scaffold_1.scaffoldRegistry)(dir);
|
|
194
|
+
if (!result.ok) {
|
|
195
|
+
console.error(picocolors_1.default.red(result.error));
|
|
196
|
+
process.exit(1);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
(0, prompts_1.outro)(`✅ Registry scaffolded at ${picocolors_1.default.cyan(result.dir)}. Run ${picocolors_1.default.cyan('awm registry doctor ' + result.dir)} to audit.`);
|
|
200
|
+
});
|
|
201
|
+
reg.command('doctor [nameOrPath]')
|
|
202
|
+
.description('audit a registry tree (structural failures vs advisories)')
|
|
203
|
+
.action((nameOrPath) => {
|
|
204
|
+
let root;
|
|
205
|
+
try {
|
|
206
|
+
root = resolveDoctorTarget(nameOrPath);
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
console.error(picocolors_1.default.red(e instanceof Error ? e.message : String(e)));
|
|
210
|
+
process.exit(1);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const report = (0, doctor_1.doctorRegistry)(root);
|
|
214
|
+
for (const msg of report.structural) {
|
|
215
|
+
console.error(picocolors_1.default.red(`structural: ${msg}`));
|
|
216
|
+
}
|
|
217
|
+
for (const msg of report.advisories) {
|
|
218
|
+
console.warn(picocolors_1.default.yellow(`advisory: ${msg}`));
|
|
219
|
+
}
|
|
220
|
+
if (report.structural.length === 0 && report.advisories.length === 0) {
|
|
221
|
+
console.log(picocolors_1.default.green(`Registry doctor: ok (${root})`));
|
|
222
|
+
}
|
|
223
|
+
else if (report.structural.length === 0) {
|
|
224
|
+
console.log(picocolors_1.default.green(`Registry doctor: ok with ${report.advisories.length} advisory(ies)`));
|
|
225
|
+
}
|
|
226
|
+
process.exit((0, doctor_1.doctorExitCode)(report));
|
|
227
|
+
});
|
|
176
228
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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.scaffoldRegistry = scaffoldRegistry;
|
|
7
|
+
// cli/src/commands/registry/scaffold.ts
|
|
8
|
+
// Logic for `awm registry scaffold`, separated from commander wiring.
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const types_1 = require("../../core/facts/types");
|
|
12
|
+
function writeJson(write, file, value) {
|
|
13
|
+
write(file, JSON.stringify(value, null, 2) + '\n');
|
|
14
|
+
}
|
|
15
|
+
function handbookFor(name) {
|
|
16
|
+
return `# ${name}
|
|
17
|
+
|
|
18
|
+
## Capabilities
|
|
19
|
+
| id | status | notes |
|
|
20
|
+
|----|--------|-------|
|
|
21
|
+
| facts | active | see ## Capability: facts |
|
|
22
|
+
|
|
23
|
+
## Capability: facts
|
|
24
|
+
|
|
25
|
+
Declare operator/machine facts in \`facts.json\`. Skills may list \`needs\` in
|
|
26
|
+
frontmatter; values live in the machine-local environment store
|
|
27
|
+
(\`AWM_HOME/environments/\`), never in this registry tree.
|
|
28
|
+
|
|
29
|
+
- Add fact definitions under \`facts\` in \`facts.json\` (\`schemaVersion: 1\`).
|
|
30
|
+
- Keep \`minCliVersion\` in \`awm-registry.json\` at or above the CLI version that
|
|
31
|
+
understands facts.
|
|
32
|
+
- Run \`awm registry doctor\` before publishing — structural failures block; advisories do not.
|
|
33
|
+
`;
|
|
34
|
+
}
|
|
35
|
+
function writeScaffoldTree(root, name, write) {
|
|
36
|
+
writeJson(write, path_1.default.join(root, 'awm-registry.json'), {
|
|
37
|
+
overrides: [],
|
|
38
|
+
minCliVersion: types_1.FACTS_MIN_CLI_VERSION,
|
|
39
|
+
});
|
|
40
|
+
writeJson(write, path_1.default.join(root, 'catalog.json'), {
|
|
41
|
+
version: 1,
|
|
42
|
+
bundles: [],
|
|
43
|
+
});
|
|
44
|
+
const skillsDir = path_1.default.join(root, 'skills');
|
|
45
|
+
fs_1.default.mkdirSync(skillsDir, { recursive: true });
|
|
46
|
+
// Track skills/ in git — an empty dir is dropped on clone and then
|
|
47
|
+
// validateRegistryLayout / awm registry add reject the tree (RF-4.1).
|
|
48
|
+
write(path_1.default.join(skillsDir, '.gitkeep'), '');
|
|
49
|
+
writeJson(write, path_1.default.join(root, 'facts.json'), {
|
|
50
|
+
schemaVersion: 1,
|
|
51
|
+
facts: {},
|
|
52
|
+
});
|
|
53
|
+
write(path_1.default.join(root, 'REGISTRY.md'), handbookFor(name));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Create a minimal registry tree that passes structural `doctorRegistry`.
|
|
57
|
+
* Atomic (RF-4.3): write into a temp sibling, then rename into place; on
|
|
58
|
+
* failure remove only the staging dir so an empty preexisting target stays
|
|
59
|
+
* empty (retryable) and a missing target stays absent.
|
|
60
|
+
*/
|
|
61
|
+
function scaffoldRegistry(dir, opts = {}) {
|
|
62
|
+
if (typeof dir !== 'string' || dir.trim() === '') {
|
|
63
|
+
return { ok: false, error: 'scaffold target directory is required' };
|
|
64
|
+
}
|
|
65
|
+
const abs = path_1.default.resolve(dir);
|
|
66
|
+
const write = opts.writeFileSync ?? ((file, data) => fs_1.default.writeFileSync(file, data));
|
|
67
|
+
let targetExistsEmpty = false;
|
|
68
|
+
if (fs_1.default.existsSync(abs)) {
|
|
69
|
+
let stat;
|
|
70
|
+
try {
|
|
71
|
+
stat = fs_1.default.lstatSync(abs);
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
error: `Cannot inspect target ${abs}: ${e instanceof Error ? e.message : String(e)}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
80
|
+
return { ok: false, error: `Target exists and is not a directory: ${abs}` };
|
|
81
|
+
}
|
|
82
|
+
if (fs_1.default.readdirSync(abs).length > 0) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: `Target directory is not empty — refuse to overwrite: ${abs}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
targetExistsEmpty = true;
|
|
89
|
+
}
|
|
90
|
+
const parent = path_1.default.dirname(abs);
|
|
91
|
+
let staging;
|
|
92
|
+
try {
|
|
93
|
+
fs_1.default.mkdirSync(parent, { recursive: true });
|
|
94
|
+
staging = fs_1.default.mkdtempSync(path_1.default.join(parent, '.awm-scaffold-'));
|
|
95
|
+
const name = path_1.default.basename(abs) || 'registry';
|
|
96
|
+
// Always stage first so mid-fail never leaves a partial target (RF-4.3).
|
|
97
|
+
writeScaffoldTree(staging, name, write);
|
|
98
|
+
if (targetExistsEmpty) {
|
|
99
|
+
// Never rmdir the empty shell before contents land. Write into abs
|
|
100
|
+
// directly from the validated staging pattern, then drop staging.
|
|
101
|
+
try {
|
|
102
|
+
writeScaffoldTree(abs, name, write);
|
|
103
|
+
}
|
|
104
|
+
catch (writeErr) {
|
|
105
|
+
// Best-effort: remove what we wrote into the preexisting empty
|
|
106
|
+
// dir without deleting the empty shell itself.
|
|
107
|
+
try {
|
|
108
|
+
for (const entry of fs_1.default.readdirSync(abs)) {
|
|
109
|
+
fs_1.default.rmSync(path_1.default.join(abs, entry), { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// best-effort
|
|
114
|
+
}
|
|
115
|
+
throw writeErr;
|
|
116
|
+
}
|
|
117
|
+
fs_1.default.rmSync(staging, { recursive: true, force: true });
|
|
118
|
+
staging = undefined;
|
|
119
|
+
return { ok: true, dir: abs };
|
|
120
|
+
}
|
|
121
|
+
// Target does not exist: atomic rename of staging into place.
|
|
122
|
+
fs_1.default.renameSync(staging, abs);
|
|
123
|
+
staging = undefined;
|
|
124
|
+
return { ok: true, dir: abs };
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
if (staging) {
|
|
128
|
+
try {
|
|
129
|
+
fs_1.default.rmSync(staging, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// best-effort cleanup; surface the original write error
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: `Scaffold failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -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
|
}
|
|
@@ -160,6 +160,9 @@ function registerWatchCommand(program) {
|
|
|
160
160
|
}
|
|
161
161
|
else {
|
|
162
162
|
process.stdout.write(`awm watch: ${outcome === 'stopped' ? 'detenido' : 'track congelado'}; ciclo ${final.cycleState ?? final.state} — sin certificacion de cierre\n`);
|
|
163
|
+
// exitCode only — never process.exit here: in-process tests call parseAsync
|
|
164
|
+
// inside Jest; a hard exit kills the worker (CI exit 130 on every OS).
|
|
165
|
+
// exitCode=130 is intentional Darwin CI flake harden (reopened-196), not operator-environments scope.
|
|
163
166
|
if (outcome === 'stopped')
|
|
164
167
|
process.exitCode = 130;
|
|
165
168
|
else if (outcome === 'complete' || final.state !== 'present')
|