agentic-workflow-manager 3.8.0 → 3.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/preflight/checks.js +123 -0
- package/dist/src/commands/preflight/index.js +50 -0
- package/dist/src/commands/sensors/changed.js +72 -2
- package/dist/src/commands/sensors/exec.js +3 -2
- package/dist/src/commands/sensors/run.js +16 -0
- package/dist/src/commands/sensors/status.js +14 -5
- package/dist/src/index.js +2 -0
- package/dist/tests/commands/preflight/preflight.test.js +123 -0
- package/dist/tests/commands/sensors/changed-windows.test.js +44 -0
- package/dist/tests/commands/sensors/changed.test.js +4 -0
- package/dist/tests/commands/sensors/exec-windows.test.js +73 -0
- package/dist/tests/commands/sensors/run-changed.test.js +35 -0
- package/dist/tests/commands/sensors/status-windows.test.js +55 -0
- package/dist/tests/commands/sensors/status.test.js +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,123 @@
|
|
|
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.preflight = preflight;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const status_1 = require("../sensors/status");
|
|
10
|
+
const init_1 = require("../sensors/init");
|
|
11
|
+
const MANIFEST = path_1.default.join('.awm', 'sensors.json');
|
|
12
|
+
/**
|
|
13
|
+
* The agent needs project context delivered every session. A repo with neither file
|
|
14
|
+
* hands every agent — and every teammate's agent — a blank slate.
|
|
15
|
+
*/
|
|
16
|
+
function checkContext(cwd) {
|
|
17
|
+
const present = ['AGENTS.md', 'CLAUDE.md', 'CONSTITUTION.md']
|
|
18
|
+
.filter(f => fs_1.default.existsSync(path_1.default.join(cwd, f)));
|
|
19
|
+
if (present.length === 0) {
|
|
20
|
+
return {
|
|
21
|
+
id: 'context',
|
|
22
|
+
ok: false,
|
|
23
|
+
detail: 'no AGENTS.md, CLAUDE.md or CONSTITUTION.md',
|
|
24
|
+
remedy: 'run the project-context-init skill (AGENTS.md) or project-constitution (CONSTITUTION.md)',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return { id: 'context', ok: true, detail: present.join(', ') };
|
|
28
|
+
}
|
|
29
|
+
function readManifest(cwd) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs_1.default.readFileSync(path_1.default.join(cwd, MANIFEST), 'utf-8'));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A repo may legitimately have no sensors — but it has to SAY so, in a committed file.
|
|
39
|
+
*
|
|
40
|
+
* That is why opting out requires a manifest with its sensors disabled rather than
|
|
41
|
+
* simply having no manifest. "We decided not to gate this repo" and "nobody ever ran
|
|
42
|
+
* `awm sensors init`" look identical from the outside, and on a team the second one is
|
|
43
|
+
* the common case. Requiring the manifest turns the decision into a reviewable diff.
|
|
44
|
+
*/
|
|
45
|
+
function checkManifest(cwd, manifest) {
|
|
46
|
+
if (!fs_1.default.existsSync(path_1.default.join(cwd, MANIFEST))) {
|
|
47
|
+
return {
|
|
48
|
+
id: 'manifest',
|
|
49
|
+
ok: false,
|
|
50
|
+
detail: 'no .awm/sensors.json',
|
|
51
|
+
remedy: 'run `awm sensors init` (to opt out deliberately, init and set every sensor '
|
|
52
|
+
+ '`"enabled": false` — an unconfigured repo and a deliberate opt-out must not look alike)',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (!manifest) {
|
|
56
|
+
return {
|
|
57
|
+
id: 'manifest',
|
|
58
|
+
ok: false,
|
|
59
|
+
detail: '.awm/sensors.json is not valid JSON',
|
|
60
|
+
remedy: 'fix or regenerate it with `awm sensors init`',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const total = Object.keys(manifest.sensors ?? {}).length;
|
|
64
|
+
const enabled = Object.values(manifest.sensors ?? {}).filter(s => s.enabled !== false).length;
|
|
65
|
+
return {
|
|
66
|
+
id: 'manifest',
|
|
67
|
+
ok: true,
|
|
68
|
+
detail: enabled === 0 ? `pack ${manifest.pack}, all ${total} sensors disabled (deliberate opt-out)`
|
|
69
|
+
: `pack ${manifest.pack}, ${enabled}/${total} sensors enabled`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Every enabled sensor's command must resolve. This is the check nothing was calling. */
|
|
73
|
+
function checkTools(cwd) {
|
|
74
|
+
const status = (0, status_1.computeSensorStatus)(cwd);
|
|
75
|
+
if (status.overall === 'NOT_CONFIGURED') {
|
|
76
|
+
return { id: 'tools', ok: false, detail: 'no manifest to check', remedy: 'run `awm sensors init`' };
|
|
77
|
+
}
|
|
78
|
+
const broken = Object.entries(status.checks).filter(([, c]) => !c.ok);
|
|
79
|
+
if (broken.length > 0) {
|
|
80
|
+
return {
|
|
81
|
+
id: 'tools',
|
|
82
|
+
ok: false,
|
|
83
|
+
detail: broken.map(([name, c]) => `${name}: ${c.detail}`).join('; '),
|
|
84
|
+
remedy: 'install the missing tools/configs, or disable those sensors deliberately',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return { id: 'tools', ok: true, detail: `${Object.keys(status.checks).length} sensor(s) runnable` };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A manifest pinned to `generic` on a tree that clearly has a stack means the real
|
|
91
|
+
* sensors for that stack are simply absent — the gate runs, reports green, and has
|
|
92
|
+
* checked almost nothing. `runSensors` self-heals this at run time via `reconcilePack`,
|
|
93
|
+
* but only when a registry is reachable; saying it out loud here costs nothing.
|
|
94
|
+
*/
|
|
95
|
+
function checkPack(cwd, manifest) {
|
|
96
|
+
if (!manifest)
|
|
97
|
+
return { id: 'pack', ok: true, detail: 'skipped (no manifest)' };
|
|
98
|
+
const detection = (0, init_1.detectStack)(cwd);
|
|
99
|
+
if (manifest.pack === 'generic' && detection.pack !== 'generic') {
|
|
100
|
+
return {
|
|
101
|
+
id: 'pack',
|
|
102
|
+
ok: false,
|
|
103
|
+
detail: `manifest on 'generic' but the tree looks like '${detection.pack}' (${detection.indicators.join(', ')})`,
|
|
104
|
+
remedy: 'run `awm sensors init` to pick up the real pack for this stack',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { id: 'pack', ok: true, detail: `${manifest.pack} matches the detected stack` };
|
|
108
|
+
}
|
|
109
|
+
function preflight(cwd = process.cwd()) {
|
|
110
|
+
const manifest = readManifest(cwd);
|
|
111
|
+
const manifestExists = fs_1.default.existsSync(path_1.default.join(cwd, MANIFEST));
|
|
112
|
+
const checks = [
|
|
113
|
+
checkContext(cwd),
|
|
114
|
+
checkManifest(cwd, manifest),
|
|
115
|
+
// Skipped when there is no manifest: reporting "tools broken" on a repo that was
|
|
116
|
+
// never set up buries the one thing the operator needs to read.
|
|
117
|
+
...(manifestExists ? [checkTools(cwd), checkPack(cwd, manifest)] : []),
|
|
118
|
+
];
|
|
119
|
+
const status = !manifestExists ? 'not_configured'
|
|
120
|
+
: checks.every(c => c.ok) ? 'ready'
|
|
121
|
+
: 'degraded';
|
|
122
|
+
return { status, checks };
|
|
123
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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.exitCodeFor = exitCodeFor;
|
|
7
|
+
exports.formatReport = formatReport;
|
|
8
|
+
exports.registerPreflightCommand = registerPreflightCommand;
|
|
9
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
10
|
+
const checks_1 = require("./checks");
|
|
11
|
+
/**
|
|
12
|
+
* Exit code. Anything but `ready` exits 1.
|
|
13
|
+
*
|
|
14
|
+
* Unlike `awm sensors run` — which exits 0 on `not_certified` because exit 2 is a
|
|
15
|
+
* blocking error in Claude Code hooks — preflight is never a hook. It is invoked
|
|
16
|
+
* explicitly by a phase gate, so the exit code can carry the verdict and the caller
|
|
17
|
+
* does not have to remember to read a field out of JSON.
|
|
18
|
+
*/
|
|
19
|
+
function exitCodeFor(report) {
|
|
20
|
+
return report.status === 'ready' ? 0 : 1;
|
|
21
|
+
}
|
|
22
|
+
function formatReport(report) {
|
|
23
|
+
const lines = report.checks.map(c => ` ${c.ok ? picocolors_1.default.green('✔') : picocolors_1.default.red('✘')} ${c.id.padEnd(9)} ${c.detail}`
|
|
24
|
+
+ (c.remedy ? `\n ${picocolors_1.default.dim('→ ' + c.remedy)}` : ''));
|
|
25
|
+
if (report.status === 'ready') {
|
|
26
|
+
return `${picocolors_1.default.green('✔')} Harness ready — this project can be gated.\n${lines.join('\n')}\n`;
|
|
27
|
+
}
|
|
28
|
+
const headline = report.status === 'not_configured'
|
|
29
|
+
? `${picocolors_1.default.red('✘')} AWM is not configured in this project.`
|
|
30
|
+
: `${picocolors_1.default.red('✘')} Harness degraded — it declares sensors it cannot run.`;
|
|
31
|
+
return `${headline}\n${lines.join('\n')}\n\n`
|
|
32
|
+
+ ` ${picocolors_1.default.bold('Do not hand this off to an unattended run.')} Every quality phase downstream\n`
|
|
33
|
+
+ ` (implementer, reviewers, post-qa) consumes \`awm sensors run\`. With the harness in\n`
|
|
34
|
+
+ ` this state the gate reports on checks that never ran, and nobody finds out until a\n`
|
|
35
|
+
+ ` bad change is already merged.\n`;
|
|
36
|
+
}
|
|
37
|
+
function registerPreflightCommand(program) {
|
|
38
|
+
program
|
|
39
|
+
.command('preflight')
|
|
40
|
+
.description('verify the project harness can actually gate before development starts')
|
|
41
|
+
.option('--json', 'emit the report as JSON')
|
|
42
|
+
.option('--cwd <path>', 'project directory to check (default: current)')
|
|
43
|
+
.action((opts) => {
|
|
44
|
+
const report = (0, checks_1.preflight)(opts.cwd ?? process.cwd());
|
|
45
|
+
process.stdout.write(opts.json ? JSON.stringify(report, null, 2) + '\n' : formatReport(report));
|
|
46
|
+
const code = exitCodeFor(report);
|
|
47
|
+
if (code !== 0)
|
|
48
|
+
process.exit(code);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -4,10 +4,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.changedFiles = changedFiles;
|
|
7
|
+
exports.hasUnsafeWin32Chars = hasUnsafeWin32Chars;
|
|
7
8
|
exports.applyChangedCmd = applyChangedCmd;
|
|
8
9
|
exports.filterByExtension = filterByExtension;
|
|
9
10
|
const child_process_1 = require("child_process");
|
|
10
11
|
const path_1 = __importDefault(require("path"));
|
|
12
|
+
const paths_1 = require("../../core/paths");
|
|
11
13
|
function git(args, cwd) {
|
|
12
14
|
return (0, child_process_1.execFileSync)('git', args, {
|
|
13
15
|
cwd,
|
|
@@ -56,14 +58,82 @@ function changedFiles(cwd, base = 'HEAD') {
|
|
|
56
58
|
const files = Array.from(new Set(out.map(s => s.trim()).filter(Boolean))).sort();
|
|
57
59
|
return { files };
|
|
58
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* cmd.exe metacharacters that quoting does NOT reliably neutralize.
|
|
63
|
+
*
|
|
64
|
+
* Per the BatBadBut / CVE-2024-27980 research (flatt.tech/research/posts/batbadbut-
|
|
65
|
+
* you-cant-securely-execute-commands-on-windows/ — the paper Node's own CVE fix was
|
|
66
|
+
* based on), `%` still triggers environment-variable expansion *inside* a
|
|
67
|
+
* double-quoted string, and `&` can break a command out of quoting under certain
|
|
68
|
+
* conditions. cmd.exe parses these BEFORE the target program ever sees argv, so no
|
|
69
|
+
* amount of `"..."`/`\"` escaping at the argv layer (which is all `shellQuote` can
|
|
70
|
+
* touch) is a complete guarantee against them. Newline/CR are included because the
|
|
71
|
+
* same research flags them, and they are nonsensical in a real path regardless.
|
|
72
|
+
*
|
|
73
|
+
* This is a denylist, not an escaping table, on purpose: the responsible fix for
|
|
74
|
+
* this vulnerability class is to REFUSE (fall back to the full, unscoped command —
|
|
75
|
+
* see `run.ts`), not to hand-roll a smarter cmd.exe escaper. Node's own security
|
|
76
|
+
* team reached the same conclusion for the identical problem.
|
|
77
|
+
*/
|
|
78
|
+
const WIN32_UNSAFE_CHARS = /[&|<>^%\r\n]/;
|
|
79
|
+
/** True when `file` contains a character cmd.exe would parse as its own syntax. */
|
|
80
|
+
function hasUnsafeWin32Chars(file) {
|
|
81
|
+
return WIN32_UNSAFE_CHARS.test(file);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* CommandLineToArgvW-safe quoting (the algorithm behind Python's
|
|
85
|
+
* `subprocess.list2cmdline`, Rust's `std::process::Command` on Windows, and .NET's
|
|
86
|
+
* argument escaper). Per the documented rule
|
|
87
|
+
* (learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments):
|
|
88
|
+
* an EVEN run of backslashes before a `"` collapses to half as many literal
|
|
89
|
+
* backslashes and the `"` is a real delimiter; an ODD run collapses the same way
|
|
90
|
+
* but the leftover backslash escapes the `"` into a literal character instead of a
|
|
91
|
+
* delimiter. Backslashes not followed by a `"` are always literal and untouched.
|
|
92
|
+
*
|
|
93
|
+
* This alone does not make interpolation safe on win32 — see `hasUnsafeWin32Chars`
|
|
94
|
+
* and its callers in `run.ts` for the metacharacter layer this cannot address.
|
|
95
|
+
*/
|
|
96
|
+
function win32ArgvQuote(arg) {
|
|
97
|
+
let result = '"';
|
|
98
|
+
let backslashes = 0;
|
|
99
|
+
for (const ch of arg) {
|
|
100
|
+
if (ch === '\\') {
|
|
101
|
+
backslashes++;
|
|
102
|
+
}
|
|
103
|
+
else if (ch === '"') {
|
|
104
|
+
result += '\\'.repeat(backslashes * 2 + 1) + '"';
|
|
105
|
+
backslashes = 0;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
result += '\\'.repeat(backslashes) + ch;
|
|
109
|
+
backslashes = 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
result += '\\'.repeat(backslashes * 2); // double any trailing run before the closing quote
|
|
113
|
+
result += '"';
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
59
116
|
/**
|
|
60
117
|
* Quote a path for a shell command line. Sensor commands are strings run through a
|
|
61
118
|
* shell, so a path with a space or a quote in it would otherwise split into two
|
|
62
119
|
* arguments — or, worse, end the quoting and let the rest of the name be read as
|
|
63
|
-
* shell syntax.
|
|
64
|
-
*
|
|
120
|
+
* shell syntax.
|
|
121
|
+
*
|
|
122
|
+
* `runCommand` (see `exec.ts`) spawns this string with `shell: true`, which on
|
|
123
|
+
* win32 is `cmd.exe`, not a POSIX shell. Single quotes are not quoting syntax to
|
|
124
|
+
* cmd.exe — it just splits on the space inside them — so a POSIX-only quote here
|
|
125
|
+
* would silently hand eslint/semgrep two garbage arguments instead of one real
|
|
126
|
+
* path. `'\''` (single quotes with the escape) is what POSIX shells treat as fully
|
|
127
|
+
* literal — no exceptions, per POSIX shell grammar, so no metacharacter denylist is
|
|
128
|
+
* needed on that branch. On win32, `win32ArgvQuote` is the argv-layer half of
|
|
129
|
+
* safety; the metacharacter denylist above (enforced by the caller in `run.ts`,
|
|
130
|
+
* before this function is ever reached) is the other half this function cannot
|
|
131
|
+
* provide on its own.
|
|
65
132
|
*/
|
|
66
133
|
function shellQuote(file) {
|
|
134
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
135
|
+
return win32ArgvQuote(file);
|
|
136
|
+
}
|
|
67
137
|
return `'${file.replace(/'/g, `'\\''`)}'`;
|
|
68
138
|
}
|
|
69
139
|
/**
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.runCommand = runCommand;
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
|
+
const paths_1 = require("../../core/paths");
|
|
5
6
|
const DEFAULT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
6
7
|
const DEFAULT_KILL_GRACE_MS = 2_000;
|
|
7
8
|
/** After SIGKILL, resolve regardless. A sensor must never hang the gate. */
|
|
@@ -22,7 +23,7 @@ const POST_KILL_GRACE_MS = 1_000;
|
|
|
22
23
|
* negative-pid kill reaches every descendant at once.
|
|
23
24
|
*/
|
|
24
25
|
function killTree(pid, signal) {
|
|
25
|
-
if (
|
|
26
|
+
if ((0, paths_1.isWindowsNative)()) {
|
|
26
27
|
// Windows has no process groups in the POSIX sense; taskkill /T walks the tree.
|
|
27
28
|
try {
|
|
28
29
|
(0, child_process_1.execFile)('taskkill', ['/pid', String(pid), '/T', '/F'], () => { });
|
|
@@ -65,7 +66,7 @@ function runCommand(cmd, opts) {
|
|
|
65
66
|
const child = (0, child_process_1.spawn)(cmd, {
|
|
66
67
|
shell: true,
|
|
67
68
|
cwd: opts.cwd,
|
|
68
|
-
detached:
|
|
69
|
+
detached: !(0, paths_1.isWindowsNative)(),
|
|
69
70
|
// stdin closed: a sensor must never block waiting for input, and the
|
|
70
71
|
// EOF also tells watch-mode-capable tools (vitest, jest) to run once.
|
|
71
72
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -21,6 +21,7 @@ const baseline_1 = require("./baseline");
|
|
|
21
21
|
const changed_1 = require("./changed");
|
|
22
22
|
const init_1 = require("./init");
|
|
23
23
|
const registries_1 = require("../../core/registries");
|
|
24
|
+
const paths_1 = require("../../core/paths");
|
|
24
25
|
const MANIFEST_FILE = '.awm/sensors.json';
|
|
25
26
|
const DEFAULT_FAST_TIMEOUT = 10_000;
|
|
26
27
|
const DEFAULT_SLOW_TIMEOUT = 120_000;
|
|
@@ -268,6 +269,21 @@ async function runSensors(opts = {}) {
|
|
|
268
269
|
// Resolved once for the whole run, not per sensor: `git` is cheap but the answer
|
|
269
270
|
// must be identical across sensors, or two of them scope to different file sets.
|
|
270
271
|
const changed = opts.changed ? (0, changed_1.changedFiles)(cwd, opts.base ?? 'HEAD') : null;
|
|
272
|
+
// Security (BatBadBut / CVE-2024-27980): on native Windows, `runCommand` spawns
|
|
273
|
+
// the sensor command through cmd.exe (`shell: true`), which parses `& | < > ^ %`
|
|
274
|
+
// as ITS OWN syntax before the target program ever sees argv — quoting does not
|
|
275
|
+
// reliably neutralize this layer (the primary research this fix is based on
|
|
276
|
+
// concludes escaping it is not safely possible). A changed filename carrying one
|
|
277
|
+
// of these is refused, not escaped: routed through the exact same fallback the
|
|
278
|
+
// module already has for "scope could not be resolved" (`changed.error`), so
|
|
279
|
+
// every sensor degrades to its full unscoped command rather than interpolating
|
|
280
|
+
// an unsafe path. POSIX is unaffected — single-quote quoting there is fully
|
|
281
|
+
// literal per POSIX shell grammar, no metacharacter exception exists.
|
|
282
|
+
if (changed && !changed.error && (0, paths_1.isWindowsNative)() && changed.files.some(changed_1.hasUnsafeWin32Chars)) {
|
|
283
|
+
changed.error = 'a changed filename contains a cmd.exe metacharacter (& | < > ^ % or newline/CR) '
|
|
284
|
+
+ 'that quoting cannot reliably neutralize on native Windows — refusing to interpolate it, '
|
|
285
|
+
+ 'falling back to the full unscoped command';
|
|
286
|
+
}
|
|
271
287
|
// Sensors are independent processes over the same tree, so they run
|
|
272
288
|
// concurrently rather than one-after-another: wall clock becomes the slowest
|
|
273
289
|
// sensor instead of the sum of all of them. Tasks are built — and dispatched —
|
|
@@ -7,6 +7,7 @@ exports.computeSensorStatus = computeSensorStatus;
|
|
|
7
7
|
const child_process_1 = require("child_process");
|
|
8
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const paths_1 = require("../../core/paths");
|
|
10
11
|
/** First non-flag token after `npx` — the tool the command actually runs. */
|
|
11
12
|
function npxTool(parts) {
|
|
12
13
|
for (let i = 1; i < parts.length; i++) {
|
|
@@ -15,6 +16,17 @@ function npxTool(parts) {
|
|
|
15
16
|
}
|
|
16
17
|
return undefined;
|
|
17
18
|
}
|
|
19
|
+
/** Resolve a binary on PATH portably: `where` on win32, POSIX `command -v` elsewhere. */
|
|
20
|
+
function resolveOnPath(bin) {
|
|
21
|
+
const cmd = (0, paths_1.isWindowsNative)() ? `where ${bin}` : `command -v ${bin}`;
|
|
22
|
+
try {
|
|
23
|
+
(0, child_process_1.execSync)(cmd, { stdio: 'pipe' });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
18
30
|
/** If the command references `--config <file>`, that file must exist in the repo. */
|
|
19
31
|
function configCheck(parts, cwd) {
|
|
20
32
|
const i = parts.indexOf('--config');
|
|
@@ -29,7 +41,7 @@ function configCheck(parts, cwd) {
|
|
|
29
41
|
* - `npx <tool>`: the tool MUST be installed locally (node_modules/.bin). Otherwise
|
|
30
42
|
* `npx` would fetch a remote package at run time (dependency-confusion risk) and
|
|
31
43
|
* the sensor would fail. A green status here would be a lie.
|
|
32
|
-
* - other binaries: must resolve on PATH (`
|
|
44
|
+
* - other binaries: must resolve on PATH (`where` on win32, `command -v` elsewhere).
|
|
33
45
|
* - any `--config <file>` referenced must exist.
|
|
34
46
|
*/
|
|
35
47
|
function checkCmd(cmd, cwd) {
|
|
@@ -48,10 +60,7 @@ function checkCmd(cmd, cwd) {
|
|
|
48
60
|
}
|
|
49
61
|
return configCheck(parts, cwd) ?? { ok: true, detail: `${tool} (node_modules/.bin)` };
|
|
50
62
|
}
|
|
51
|
-
|
|
52
|
-
(0, child_process_1.execSync)(`which ${bin}`, { stdio: 'pipe' });
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
63
|
+
if (!resolveOnPath(bin)) {
|
|
55
64
|
return { ok: false, detail: `${bin} not found in PATH` };
|
|
56
65
|
}
|
|
57
66
|
return configCheck(parts, cwd) ?? { ok: true, detail: bin };
|
package/dist/src/index.js
CHANGED
|
@@ -27,6 +27,7 @@ const hooks_1 = require("./commands/hooks");
|
|
|
27
27
|
const sensors_1 = require("./commands/sensors");
|
|
28
28
|
const ledger_1 = require("./commands/ledger");
|
|
29
29
|
const context_budget_1 = require("./commands/context-budget");
|
|
30
|
+
const preflight_1 = require("./commands/preflight");
|
|
30
31
|
const doctor_1 = require("./commands/doctor");
|
|
31
32
|
const backup_1 = require("./commands/backup");
|
|
32
33
|
const init_1 = require("./commands/init");
|
|
@@ -608,6 +609,7 @@ miroCmd.command('sync <storyMapPath>')
|
|
|
608
609
|
(0, sensors_1.registerSensorsCommand)(program);
|
|
609
610
|
(0, ledger_1.registerLedgerCommand)(program);
|
|
610
611
|
(0, context_budget_1.registerContextBudgetCommand)(program);
|
|
612
|
+
(0, preflight_1.registerPreflightCommand)(program);
|
|
611
613
|
(0, doctor_1.registerDoctorCommand)(program);
|
|
612
614
|
(0, backup_1.registerBackupCommand)(program);
|
|
613
615
|
(0, init_1.registerInitCommand)(program);
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const checks_1 = require("../../../src/commands/preflight/checks");
|
|
10
|
+
const preflight_1 = require("../../../src/commands/preflight");
|
|
11
|
+
/** CLAUDE.md: no test may reach the real ~/.awm. Everything here is a tmpdir. */
|
|
12
|
+
function project(opts = {}) {
|
|
13
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-preflight-'));
|
|
14
|
+
for (const f of opts.context ?? ['AGENTS.md'])
|
|
15
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, f), '# ctx\n');
|
|
16
|
+
for (const f of opts.files ?? []) {
|
|
17
|
+
fs_1.default.mkdirSync(path_1.default.dirname(path_1.default.join(dir, f)), { recursive: true });
|
|
18
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, f), '');
|
|
19
|
+
}
|
|
20
|
+
for (const b of opts.bins ?? []) {
|
|
21
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, 'node_modules', '.bin'), { recursive: true });
|
|
22
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, 'node_modules', '.bin', b), '');
|
|
23
|
+
}
|
|
24
|
+
if (opts.manifest !== undefined) {
|
|
25
|
+
fs_1.default.mkdirSync(path_1.default.join(dir, '.awm'), { recursive: true });
|
|
26
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, '.awm', 'sensors.json'), typeof opts.manifest === 'string' ? opts.manifest : JSON.stringify(opts.manifest));
|
|
27
|
+
}
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
const dirs = [];
|
|
31
|
+
const make = (o) => { const d = project(o); dirs.push(d); return d; };
|
|
32
|
+
afterAll(() => dirs.forEach(d => fs_1.default.rmSync(d, { recursive: true, force: true })));
|
|
33
|
+
const check = (r, id) => r.checks.find(c => c.id === id);
|
|
34
|
+
describe('preflight', () => {
|
|
35
|
+
it('reports not_configured when no sensor manifest exists', () => {
|
|
36
|
+
// The team-rollout case: a developer clones the repo and never runs
|
|
37
|
+
// `awm sensors init`. Today nothing notices until an unattended run is already
|
|
38
|
+
// in flight and every quality phase is consuming a gate that certifies nothing.
|
|
39
|
+
const dir = make();
|
|
40
|
+
const report = (0, checks_1.preflight)(dir);
|
|
41
|
+
expect(report.status).toBe('not_configured');
|
|
42
|
+
expect(check(report, 'manifest').ok).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
it('keeps not_configured and degraded apart', () => {
|
|
45
|
+
// "You never set this up" and "you set it up and it broke" need different
|
|
46
|
+
// remedies. Collapsing them is how an absent check reads as a passing one.
|
|
47
|
+
const never = make();
|
|
48
|
+
const broken = make({
|
|
49
|
+
manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } }, // no local eslint
|
|
50
|
+
});
|
|
51
|
+
expect((0, checks_1.preflight)(never).status).toBe('not_configured');
|
|
52
|
+
expect((0, checks_1.preflight)(broken).status).toBe('degraded');
|
|
53
|
+
});
|
|
54
|
+
it('is ready when the declared sensors can actually run', () => {
|
|
55
|
+
const dir = make({
|
|
56
|
+
manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
|
|
57
|
+
bins: ['eslint'],
|
|
58
|
+
files: ['package.json'],
|
|
59
|
+
});
|
|
60
|
+
const report = (0, checks_1.preflight)(dir);
|
|
61
|
+
expect(report.status).toBe('ready');
|
|
62
|
+
expect((0, preflight_1.exitCodeFor)(report)).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
it('catches a sensor whose tool is not installed locally', () => {
|
|
65
|
+
// This is the check that existed and nothing in the flow was calling.
|
|
66
|
+
const dir = make({
|
|
67
|
+
manifest: { pack: 'js-ts', sensors: { lint: { cmd: 'npx eslint .' } } },
|
|
68
|
+
files: ['package.json'],
|
|
69
|
+
});
|
|
70
|
+
const report = (0, checks_1.preflight)(dir);
|
|
71
|
+
expect(report.status).toBe('degraded');
|
|
72
|
+
expect(check(report, 'tools').detail).toContain('eslint');
|
|
73
|
+
});
|
|
74
|
+
it('accepts a deliberate opt-out, but only when it is written down', () => {
|
|
75
|
+
// A repo may legitimately have no sensors — it just has to SAY so in a committed
|
|
76
|
+
// file, so "we decided not to gate this" cannot be mistaken for "nobody set it up".
|
|
77
|
+
const optedOut = make({
|
|
78
|
+
manifest: { pack: 'generic', sensors: { security: { cmd: 'semgrep .', enabled: false } } },
|
|
79
|
+
});
|
|
80
|
+
const report = (0, checks_1.preflight)(optedOut);
|
|
81
|
+
expect(report.status).toBe('ready');
|
|
82
|
+
expect(check(report, 'manifest').detail).toContain('opt-out');
|
|
83
|
+
});
|
|
84
|
+
it('flags a manifest stuck on generic while the tree has a real stack', () => {
|
|
85
|
+
// The gate would run, report green, and have checked almost nothing.
|
|
86
|
+
const dir = make({
|
|
87
|
+
manifest: { pack: 'generic', sensors: {} },
|
|
88
|
+
files: ['package.json'],
|
|
89
|
+
});
|
|
90
|
+
const report = (0, checks_1.preflight)(dir);
|
|
91
|
+
expect(report.status).toBe('degraded');
|
|
92
|
+
expect(check(report, 'pack').ok).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
it('flags a repo with no context contract at all', () => {
|
|
95
|
+
const dir = make({
|
|
96
|
+
context: [],
|
|
97
|
+
manifest: { pack: 'generic', sensors: {} },
|
|
98
|
+
});
|
|
99
|
+
expect(check((0, checks_1.preflight)(dir), 'context').ok).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
it('treats an unparseable manifest as a failure, not as absent', () => {
|
|
102
|
+
const dir = make({ manifest: '{ not json' });
|
|
103
|
+
const report = (0, checks_1.preflight)(dir);
|
|
104
|
+
expect(report.status).toBe('degraded');
|
|
105
|
+
expect(check(report, 'manifest').detail).toContain('not valid JSON');
|
|
106
|
+
});
|
|
107
|
+
it('exits non-zero for anything but ready, so the caller need not parse JSON', () => {
|
|
108
|
+
// Unlike `awm sensors run` — which exits 0 on not_certified because exit 2 blocks
|
|
109
|
+
// Claude Code hooks — preflight is never a hook, so the verdict rides the exit code
|
|
110
|
+
// instead of depending on every agent remembering to read a field.
|
|
111
|
+
expect((0, preflight_1.exitCodeFor)({ status: 'not_configured', checks: [] })).toBe(1);
|
|
112
|
+
expect((0, preflight_1.exitCodeFor)({ status: 'degraded', checks: [] })).toBe(1);
|
|
113
|
+
expect((0, preflight_1.exitCodeFor)({ status: 'ready', checks: [] })).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
it('tells the operator not to hand a broken harness to an unattended run', () => {
|
|
116
|
+
const out = (0, preflight_1.formatReport)({
|
|
117
|
+
status: 'not_configured',
|
|
118
|
+
checks: [{ id: 'manifest', ok: false, detail: 'no .awm/sensors.json', remedy: 'run `awm sensors init`' }],
|
|
119
|
+
});
|
|
120
|
+
expect(out).toContain('unattended');
|
|
121
|
+
expect(out).toContain('awm sensors init');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const changed_1 = require("../../../src/commands/sensors/changed");
|
|
4
|
+
describe('applyChangedCmd — Windows quoting', () => {
|
|
5
|
+
const originalPlatform = process.platform;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
8
|
+
});
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
11
|
+
});
|
|
12
|
+
it('double-quotes paths on win32 instead of POSIX single-quoting', () => {
|
|
13
|
+
// `runCommand` spawns this string with `shell: true`, which is cmd.exe on
|
|
14
|
+
// win32. cmd.exe does not treat single quotes as quoting syntax — it would
|
|
15
|
+
// split `'my dir/a.ts'` into two garbage arguments on the space. Double
|
|
16
|
+
// quotes are the form cmd.exe actually honors.
|
|
17
|
+
expect((0, changed_1.applyChangedCmd)('eslint {files}', ['my dir/a.ts']))
|
|
18
|
+
.toBe(`eslint "my dir/a.ts"`);
|
|
19
|
+
});
|
|
20
|
+
it('escapes an embedded double quote with a preceding backslash', () => {
|
|
21
|
+
expect((0, changed_1.applyChangedCmd)('eslint {files}', ['weird"name.ts']))
|
|
22
|
+
.toBe(`eslint "weird\\"name.ts"`);
|
|
23
|
+
});
|
|
24
|
+
it('handles a filename with both a space and an embedded quote together', () => {
|
|
25
|
+
expect((0, changed_1.applyChangedCmd)('eslint {files}', ['my dir/it"s.ts']))
|
|
26
|
+
.toBe(`eslint "my dir/it\\"s.ts"`);
|
|
27
|
+
});
|
|
28
|
+
it('doubles a lone trailing backslash so the closing quote is not escaped away', () => {
|
|
29
|
+
// Bug 1 (correctness): a filename ending in a single `\` (e.g. a scoped path
|
|
30
|
+
// like `report\`), naively closed with `..."report\""`, puts an ODD number of
|
|
31
|
+
// backslashes (1) directly before the closing `"`. Per the documented
|
|
32
|
+
// CommandLineToArgvW rule (learn.microsoft.com/en-us/cpp/c-language/parsing-c-command-line-arguments),
|
|
33
|
+
// an odd backslash run before a `"` consumes the backslashes in pairs (0
|
|
34
|
+
// literal here) and the last one escapes the quote into a literal character —
|
|
35
|
+
// so the wrapper never closes and the argument is corrupted/unterminated.
|
|
36
|
+
//
|
|
37
|
+
// The correct output doubles the trailing run to an EVEN count (2) before the
|
|
38
|
+
// closing quote: even backslashes before a `"` collapse to half as many
|
|
39
|
+
// literal backslashes (1) and the `"` is read as a real delimiter, closing the
|
|
40
|
+
// wrapper cleanly and recovering exactly the original single trailing `\`.
|
|
41
|
+
expect((0, changed_1.applyChangedCmd)('eslint {files}', ['report\\']))
|
|
42
|
+
.toBe(`eslint "report\\\\"`);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -93,6 +93,10 @@ describe('applyChangedCmd', () => {
|
|
|
93
93
|
expect((0, changed_1.applyChangedCmd)('eslint {files}', ["it's.ts"]))
|
|
94
94
|
.toBe(`eslint 'it'\\''s.ts'`);
|
|
95
95
|
});
|
|
96
|
+
it('handles a filename with both a space and an embedded quote together', () => {
|
|
97
|
+
expect((0, changed_1.applyChangedCmd)('eslint {files}', ["my dir/it's.ts"]))
|
|
98
|
+
.toBe(`eslint 'my dir/it'\\''s.ts'`);
|
|
99
|
+
});
|
|
96
100
|
});
|
|
97
101
|
describe('filterByExtension', () => {
|
|
98
102
|
it('drops files the sensor cannot be handed', () => {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const events_1 = require("events");
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
const exec_1 = require("../../../src/commands/sensors/exec");
|
|
6
|
+
jest.mock('child_process', () => ({
|
|
7
|
+
spawn: jest.fn(),
|
|
8
|
+
execFile: jest.fn(),
|
|
9
|
+
}));
|
|
10
|
+
const mockSpawn = child_process_1.spawn;
|
|
11
|
+
const mockExecFile = child_process_1.execFile;
|
|
12
|
+
/** Minimal stand-in for a ChildProcess: enough surface for exec.ts to drive. */
|
|
13
|
+
function fakeChild(pid = 4242) {
|
|
14
|
+
const child = new events_1.EventEmitter();
|
|
15
|
+
child.pid = pid;
|
|
16
|
+
child.stdout = new events_1.EventEmitter();
|
|
17
|
+
child.stderr = new events_1.EventEmitter();
|
|
18
|
+
child.unref = jest.fn();
|
|
19
|
+
return child;
|
|
20
|
+
}
|
|
21
|
+
describe('runCommand — win32', () => {
|
|
22
|
+
const originalPlatform = process.platform;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
25
|
+
mockSpawn.mockReset();
|
|
26
|
+
mockExecFile.mockReset();
|
|
27
|
+
});
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
30
|
+
jest.useRealTimers();
|
|
31
|
+
});
|
|
32
|
+
it('spawns with detached: false — win32 has no POSIX process groups to detach into', async () => {
|
|
33
|
+
const child = fakeChild();
|
|
34
|
+
mockSpawn.mockReturnValue(child);
|
|
35
|
+
const pending = (0, exec_1.runCommand)('echo hi', { timeout: 5000, cwd: process.cwd() });
|
|
36
|
+
expect(mockSpawn).toHaveBeenCalledWith('echo hi', expect.objectContaining({ detached: false }));
|
|
37
|
+
child.emit('close', 0, null);
|
|
38
|
+
const r = await pending;
|
|
39
|
+
expect(r.code).toBe(0);
|
|
40
|
+
});
|
|
41
|
+
it('kills via `taskkill /pid <pid> /T /F` on timeout, never the POSIX process.kill(-pid) path', async () => {
|
|
42
|
+
jest.useFakeTimers();
|
|
43
|
+
const child = fakeChild(4242);
|
|
44
|
+
mockSpawn.mockReturnValue(child);
|
|
45
|
+
mockExecFile.mockImplementation(((...args) => {
|
|
46
|
+
const cb = args[args.length - 1];
|
|
47
|
+
if (typeof cb === 'function')
|
|
48
|
+
cb(null, '', '');
|
|
49
|
+
return {};
|
|
50
|
+
}));
|
|
51
|
+
const posixKillSpy = jest.spyOn(process, 'kill').mockImplementation(() => true);
|
|
52
|
+
const pending = (0, exec_1.runCommand)('slow-command', {
|
|
53
|
+
timeout: 1000,
|
|
54
|
+
cwd: process.cwd(),
|
|
55
|
+
killGraceMs: 500,
|
|
56
|
+
});
|
|
57
|
+
// Fire the deadline: cutShort() -> killTree(pid, 'SIGTERM').
|
|
58
|
+
jest.advanceTimersByTime(1000);
|
|
59
|
+
expect(mockExecFile).toHaveBeenCalledWith('taskkill', ['/pid', '4242', '/T', '/F'], expect.any(Function));
|
|
60
|
+
// The win32 branch returns before ever reaching the POSIX fallback.
|
|
61
|
+
expect(posixKillSpy).not.toHaveBeenCalled();
|
|
62
|
+
// Escalation to SIGKILL, then the post-kill grace that resolves regardless.
|
|
63
|
+
jest.advanceTimersByTime(500);
|
|
64
|
+
expect(mockExecFile).toHaveBeenCalledWith('taskkill', ['/pid', '4242', '/T', '/F'], expect.any(Function));
|
|
65
|
+
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
|
66
|
+
expect(posixKillSpy).not.toHaveBeenCalled();
|
|
67
|
+
jest.advanceTimersByTime(1000);
|
|
68
|
+
const r = await pending;
|
|
69
|
+
expect(r.timedOut).toBe(true);
|
|
70
|
+
expect(r.signal).toBe('SIGKILL');
|
|
71
|
+
posixKillSpy.mockRestore();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -133,4 +133,39 @@ describe('runSensors --changed', () => {
|
|
|
133
133
|
await load().runSensors({ cwd: dir, changed: true, base: 'main' });
|
|
134
134
|
expect(mockChangedFiles).toHaveBeenCalledWith(dir, 'main');
|
|
135
135
|
});
|
|
136
|
+
describe('on native Windows, with an unsafe changed filename', () => {
|
|
137
|
+
// Bug 2 (security): `runCommand` (exec.ts) spawns the sensor command with
|
|
138
|
+
// `shell: true`, which on win32 is cmd.exe — it parses `& | < > ^ %` as its
|
|
139
|
+
// OWN metacharacters BEFORE the target program ever sees argv, regardless of
|
|
140
|
+
// `"..."` wrapping in many cases (BatBadBut / CVE-2024-27980 research: `%`
|
|
141
|
+
// still triggers variable expansion inside double-quoted strings, and `&` can
|
|
142
|
+
// break out of a quoted string). Escaping these reliably is a known-unreliable
|
|
143
|
+
// approach, so the fix REFUSES: any changed file with one of these characters
|
|
144
|
+
// (on native Windows only) makes the whole scope unsafe to interpolate, and
|
|
145
|
+
// the run degrades to the sensor's full unscoped command instead — the exact
|
|
146
|
+
// fallback already used when `changedFiles()` cannot resolve the scope at all.
|
|
147
|
+
const originalPlatform = process.platform;
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
150
|
+
});
|
|
151
|
+
afterEach(() => {
|
|
152
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
153
|
+
});
|
|
154
|
+
it('falls back to the full command instead of interpolating the unsafe filename', async () => {
|
|
155
|
+
dir = project({ lint: LINT });
|
|
156
|
+
mockChangedFiles.mockReturnValue({ files: ['src/a.ts', 'evil&name.ts'] });
|
|
157
|
+
const out = await load().runSensors({ cwd: dir, changed: true });
|
|
158
|
+
// The unsafe filename must never reach the dispatched command line, quoted
|
|
159
|
+
// or otherwise — assert on the actual command, not just "no crash".
|
|
160
|
+
expect(cmds()).toContain('eslint --format json .');
|
|
161
|
+
expect(cmds().join(' ')).not.toContain('evil&name.ts');
|
|
162
|
+
expect(out.sensors[0].scope).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
it('reports the unsafe scope the same way an unresolved scope is reported', async () => {
|
|
165
|
+
dir = project({ lint: LINT });
|
|
166
|
+
mockChangedFiles.mockReturnValue({ files: ['evil&name.ts'] });
|
|
167
|
+
const out = await load().runSensors({ cwd: dir, changed: true });
|
|
168
|
+
expect(out.changedScope?.error).toBeDefined();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
136
171
|
});
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const status_1 = require("../../../src/commands/sensors/status");
|
|
11
|
+
jest.mock('child_process', () => ({ execSync: jest.fn() }));
|
|
12
|
+
const mockExecSync = child_process_1.execSync;
|
|
13
|
+
describe('computeSensorStatus — Windows PATH resolution', () => {
|
|
14
|
+
let tmpDir;
|
|
15
|
+
const originalPlatform = process.platform;
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
tmpDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-status-win-'));
|
|
18
|
+
mockExecSync.mockReset();
|
|
19
|
+
Object.defineProperty(process, 'platform', { value: 'win32' });
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
fs_1.default.rmSync(tmpDir, { recursive: true });
|
|
23
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
24
|
+
});
|
|
25
|
+
it('resolves an installed binary on win32 using `where`, not `which`', () => {
|
|
26
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
|
|
27
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
|
|
28
|
+
pack: 'js-ts',
|
|
29
|
+
sensors: { security: { cmd: 'semgrep --json .', fast: false } }
|
|
30
|
+
}));
|
|
31
|
+
mockExecSync.mockImplementation(((cmd) => {
|
|
32
|
+
if (cmd.startsWith('where '))
|
|
33
|
+
return Buffer.from('C:\\tools\\semgrep.exe');
|
|
34
|
+
throw new Error(`not found: ${cmd}`);
|
|
35
|
+
}));
|
|
36
|
+
const result = (0, status_1.computeSensorStatus)(tmpDir);
|
|
37
|
+
expect(result.overall).toBe('HEALTHY');
|
|
38
|
+
expect(result.checks.security.ok).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it('reports ok:false on win32 when `where` cannot find the binary', () => {
|
|
41
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
|
|
42
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
|
|
43
|
+
pack: 'js-ts',
|
|
44
|
+
sensors: { security: { cmd: 'semgrep --json .', fast: false } }
|
|
45
|
+
}));
|
|
46
|
+
mockExecSync.mockImplementation(((cmd) => {
|
|
47
|
+
if (cmd.startsWith('where '))
|
|
48
|
+
throw new Error(`not found: ${cmd}`);
|
|
49
|
+
throw new Error(`not found: ${cmd}`);
|
|
50
|
+
}));
|
|
51
|
+
const result = (0, status_1.computeSensorStatus)(tmpDir);
|
|
52
|
+
expect(result.overall).toBe('DEGRADED');
|
|
53
|
+
expect(result.checks.security.ok).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -85,6 +85,30 @@ describe('computeSensorStatus', () => {
|
|
|
85
85
|
expect(result.overall).toBe('DEGRADED');
|
|
86
86
|
expect(result.checks.security.ok).toBe(false);
|
|
87
87
|
});
|
|
88
|
+
describe('on POSIX', () => {
|
|
89
|
+
const originalPlatform = process.platform;
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
Object.defineProperty(process, 'platform', { value: 'linux' });
|
|
92
|
+
});
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
|
95
|
+
});
|
|
96
|
+
it('resolves an installed binary using `command -v`, not `where`', () => {
|
|
97
|
+
fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
|
|
98
|
+
fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
|
|
99
|
+
pack: 'js-ts',
|
|
100
|
+
sensors: { security: { cmd: 'semgrep --json .', fast: false } }
|
|
101
|
+
}));
|
|
102
|
+
mockExecSync.mockImplementation(((cmd) => {
|
|
103
|
+
if (cmd === 'command -v semgrep')
|
|
104
|
+
return Buffer.from('/usr/bin/semgrep');
|
|
105
|
+
throw new Error(`not found: ${cmd}`);
|
|
106
|
+
}));
|
|
107
|
+
const result = (0, status_1.computeSensorStatus)(tmpDir);
|
|
108
|
+
expect(result.overall).toBe('HEALTHY');
|
|
109
|
+
expect(result.checks.security.ok).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
88
112
|
it('marks disabled sensors as ok', () => {
|
|
89
113
|
fs_1.default.mkdirSync(path_1.default.join(tmpDir, '.awm'), { recursive: true });
|
|
90
114
|
fs_1.default.writeFileSync(path_1.default.join(tmpDir, '.awm', 'sensors.json'), JSON.stringify({
|