agentic-workflow-manager 3.9.0 → 3.10.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.
@@ -4,10 +4,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.preflight = preflight;
7
+ const child_process_1 = require("child_process");
7
8
  const fs_1 = __importDefault(require("fs"));
8
9
  const path_1 = __importDefault(require("path"));
9
10
  const status_1 = require("../sensors/status");
10
11
  const init_1 = require("../sensors/init");
12
+ const paths_1 = require("../../core/paths");
11
13
  const MANIFEST = path_1.default.join('.awm', 'sensors.json');
12
14
  /**
13
15
  * The agent needs project context delivered every session. A repo with neither file
@@ -106,6 +108,89 @@ function checkPack(cwd, manifest) {
106
108
  }
107
109
  return { id: 'pack', ok: true, detail: `${manifest.pack} matches the detected stack` };
108
110
  }
111
+ /**
112
+ * Extract just the hostname portion of a git remote URL — never match against the
113
+ * full URL string. A bare substring check against the whole remote (`remote.includes
114
+ * ('gitlab')`) false-positives on an org/repo name that happens to contain the word,
115
+ * e.g. `git@github.enterprise.internal:kodria/gitlab-migration-tool.git` is a GitHub
116
+ * Enterprise remote, not GitLab — "gitlab" only appears in the repo name.
117
+ *
118
+ * Covers the two common remote URL shapes:
119
+ * HTTPS: `https://github.com/org/repo.git` -> `github.com`
120
+ * SSH: `git@github.com:org/repo.git` -> `github.com`
121
+ *
122
+ * Scheme-prefixed remotes (`https://`, `ssh://`, ...) are parsed with the built-in
123
+ * `URL` class rather than a hand-rolled regex — `.hostname` is spec-defined to exclude
124
+ * both userinfo (`user:pass@`/`user@`) and `:port`, so a userinfo or password/token
125
+ * that happens to contain "github"/"gitlab" (e.g. an SSH username `ssh://gitlab@host/`
126
+ * or a CI credential-injection URL `https://x-access-token:$TOKEN@host/...`) can never
127
+ * leak into the matched host, and IPv6 literals in brackets are also handled correctly.
128
+ *
129
+ * The SCP-style shorthand (`user@host:path`, no scheme — not a real URI, so `URL`
130
+ * rejects it) falls back to a regex whose host-capture group excludes `@`, so a second
131
+ * `@` in the remote (`user@host@evil:path`) can't smuggle a bogus "host" past the colon
132
+ * check either — it simply fails to match and returns `undefined`.
133
+ *
134
+ * Returns `undefined` when neither shape matches, so callers fall through to the same
135
+ * "unrecognized host" handling as any other unmatched URL.
136
+ */
137
+ function extractHost(remote) {
138
+ try {
139
+ return new URL(remote).hostname;
140
+ }
141
+ catch {
142
+ // Not a valid URL — likely git's SCP-like shorthand (user@host:path, no scheme).
143
+ // Node's URL class does not parse this form (it's not a real URI).
144
+ }
145
+ const scpMatch = remote.match(/^[^@\s]+@([^:\s@]+):/);
146
+ return scpMatch?.[1];
147
+ }
148
+ /**
149
+ * Advisory only — `ok` is ALWAYS `true` here, no matter what it finds. The
150
+ * `finishing-a-development-branch`/`receiving-code-review` skills detect the git host
151
+ * (GitHub vs GitLab) and shell out to `gh`/`glab` to open a PR/MR, degrading honestly
152
+ * when neither is on PATH. This check tells the operator, in advance, whether that
153
+ * downstream step will actually work — but plenty of legitimate workflows never create
154
+ * a PR/MR at all (merge locally, keep the branch as-is), so gating the whole harness on
155
+ * missing tooling here would be wrong. It is FYI, never a blocker.
156
+ */
157
+ function checkHost(cwd) {
158
+ let remote;
159
+ try {
160
+ remote = (0, child_process_1.execFileSync)('git', ['remote', 'get-url', 'origin'], {
161
+ cwd,
162
+ encoding: 'utf-8',
163
+ stdio: ['ignore', 'pipe', 'ignore'], // no origin / not a repo prints to stderr — keep it off the operator's screen
164
+ }).trim();
165
+ }
166
+ catch {
167
+ // No `origin`, or not a git repo at all — nothing to advise on.
168
+ return { id: 'host', ok: true, detail: 'no git remote detected — PR/MR automation not applicable' };
169
+ }
170
+ const host = extractHost(remote);
171
+ if (host?.includes('github.com')) {
172
+ return (0, paths_1.resolveOnPath)('gh')
173
+ ? { id: 'host', ok: true, detail: 'github detected, gh available' }
174
+ : {
175
+ id: 'host',
176
+ ok: true,
177
+ detail: 'github detected, gh not on PATH — PR creation will require manual steps',
178
+ remedy: 'install the GitHub CLI (gh), or PR creation will need to be done manually',
179
+ };
180
+ }
181
+ if (host?.includes('gitlab')) {
182
+ return (0, paths_1.resolveOnPath)('glab')
183
+ ? { id: 'host', ok: true, detail: 'gitlab detected, glab available' }
184
+ : {
185
+ id: 'host',
186
+ ok: true,
187
+ detail: 'gitlab detected, glab not on PATH — MR creation will require manual steps',
188
+ remedy: 'install the GitLab CLI (glab), or MR creation will need to be done manually',
189
+ };
190
+ }
191
+ // Bitbucket, Azure DevOps, an internal git server, etc. — don't overclaim support.
192
+ return { id: 'host', ok: true, detail: 'git host not recognized (github/gitlab) — PR/MR automation not applicable' };
193
+ }
109
194
  function preflight(cwd = process.cwd()) {
110
195
  const manifest = readManifest(cwd);
111
196
  const manifestExists = fs_1.default.existsSync(path_1.default.join(cwd, MANIFEST));
@@ -115,6 +200,9 @@ function preflight(cwd = process.cwd()) {
115
200
  // Skipped when there is no manifest: reporting "tools broken" on a repo that was
116
201
  // never set up buries the one thing the operator needs to read.
117
202
  ...(manifestExists ? [checkTools(cwd), checkPack(cwd, manifest)] : []),
203
+ // Runs unconditionally — orthogonal to sensor configuration entirely, this is
204
+ // about PR/MR tooling, not sensors.
205
+ checkHost(cwd),
118
206
  ];
119
207
  const status = !manifestExists ? 'not_configured'
120
208
  : checks.every(c => c.ok) ? 'ready'
@@ -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. Single quotes with the `'\''` escape are the only form POSIX shells
64
- * treat as fully literal.
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 (process.platform === 'win32') {
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: process.platform !== 'win32',
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 —
@@ -4,9 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.computeSensorStatus = computeSensorStatus;
7
- const child_process_1 = require("child_process");
8
7
  const fs_1 = __importDefault(require("fs"));
9
8
  const path_1 = __importDefault(require("path"));
9
+ const paths_1 = require("../../core/paths");
10
10
  /** First non-flag token after `npx` — the tool the command actually runs. */
11
11
  function npxTool(parts) {
12
12
  for (let i = 1; i < parts.length; i++) {
@@ -29,7 +29,7 @@ function configCheck(parts, cwd) {
29
29
  * - `npx <tool>`: the tool MUST be installed locally (node_modules/.bin). Otherwise
30
30
  * `npx` would fetch a remote package at run time (dependency-confusion risk) and
31
31
  * the sensor would fail. A green status here would be a lie.
32
- * - other binaries: must resolve on PATH (`which`).
32
+ * - other binaries: must resolve on PATH (`where` on win32, `command -v` elsewhere).
33
33
  * - any `--config <file>` referenced must exist.
34
34
  */
35
35
  function checkCmd(cmd, cwd) {
@@ -48,10 +48,7 @@ function checkCmd(cmd, cwd) {
48
48
  }
49
49
  return configCheck(parts, cwd) ?? { ok: true, detail: `${tool} (node_modules/.bin)` };
50
50
  }
51
- try {
52
- (0, child_process_1.execSync)(`which ${bin}`, { stdio: 'pipe' });
53
- }
54
- catch {
51
+ if (!(0, paths_1.resolveOnPath)(bin)) {
55
52
  return { ok: false, detail: `${bin} not found in PATH` };
56
53
  }
57
54
  return configCheck(parts, cwd) ?? { ok: true, detail: bin };
@@ -10,6 +10,7 @@ exports.platform = platform;
10
10
  exports.isWindowsNative = isWindowsNative;
11
11
  exports.platformLabel = platformLabel;
12
12
  exports.warnIfUnsupportedPlatform = warnIfUnsupportedPlatform;
13
+ exports.resolveOnPath = resolveOnPath;
13
14
  // cli/src/core/paths.ts
14
15
  //
15
16
  // Single source of truth for home / AWM_HOME resolution and platform detection.
@@ -17,6 +18,7 @@ exports.warnIfUnsupportedPlatform = warnIfUnsupportedPlatform;
17
18
  // always honored and tests need no jest.resetModules().
18
19
  const os_1 = __importDefault(require("os"));
19
20
  const path_1 = __importDefault(require("path"));
21
+ const child_process_1 = require("child_process");
20
22
  /** User home directory with a robust fallback. Never returns a raw, possibly-empty process.env.HOME. */
21
23
  function homeDir() {
22
24
  return process.env.HOME || os_1.default.homedir();
@@ -54,3 +56,14 @@ function warnIfUnsupportedPlatform(log) {
54
56
  if (isWindowsNative())
55
57
  log(exports.WINDOWS_NATIVE_WARNING);
56
58
  }
59
+ /** Resolve a binary on PATH portably: `where` on win32, POSIX `command -v` elsewhere. */
60
+ function resolveOnPath(bin) {
61
+ const cmd = isWindowsNative() ? `where ${bin}` : `command -v ${bin}`;
62
+ try {
63
+ (0, child_process_1.execSync)(cmd, { stdio: 'pipe' });
64
+ return true;
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
@@ -6,8 +6,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const fs_1 = __importDefault(require("fs"));
7
7
  const os_1 = __importDefault(require("os"));
8
8
  const path_1 = __importDefault(require("path"));
9
+ const child_process_1 = require("child_process");
9
10
  const checks_1 = require("../../../src/commands/preflight/checks");
10
11
  const preflight_1 = require("../../../src/commands/preflight");
12
+ // Only `execSync` (used by `resolveOnPath` to check for `gh`/`glab`) is mocked — `git
13
+ // remote get-url origin` runs for real via `execFileSync` against real tmpdir git repos,
14
+ // same as every other check in this file exercises the real filesystem.
15
+ jest.mock('child_process', () => ({
16
+ ...jest.requireActual('child_process'),
17
+ execSync: jest.fn(),
18
+ }));
19
+ const mockExecSync = child_process_1.execSync;
20
+ /** Turn a tmpdir into a real git repo with (optionally) an `origin` remote. */
21
+ function gitRepo(dir, remoteUrl) {
22
+ (0, child_process_1.execFileSync)('git', ['init'], { cwd: dir, stdio: 'pipe' });
23
+ if (remoteUrl)
24
+ (0, child_process_1.execFileSync)('git', ['remote', 'add', 'origin', remoteUrl], { cwd: dir, stdio: 'pipe' });
25
+ }
11
26
  /** CLAUDE.md: no test may reach the real ~/.awm. Everything here is a tmpdir. */
12
27
  function project(opts = {}) {
13
28
  const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-preflight-'));
@@ -112,6 +127,156 @@ describe('preflight', () => {
112
127
  expect((0, preflight_1.exitCodeFor)({ status: 'degraded', checks: [] })).toBe(1);
113
128
  expect((0, preflight_1.exitCodeFor)({ status: 'ready', checks: [] })).toBe(0);
114
129
  });
130
+ describe('host check (advisory — never changes the exit code)', () => {
131
+ beforeEach(() => { mockExecSync.mockReset(); });
132
+ it('reports github + gh available, and does not affect status', () => {
133
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
134
+ gitRepo(dir, 'git@github.com:kodria/agentic-workflow.git');
135
+ mockExecSync.mockImplementation(((cmd) => {
136
+ if (cmd === 'command -v gh')
137
+ return Buffer.from('/usr/bin/gh');
138
+ throw new Error(`not found: ${cmd}`);
139
+ }));
140
+ const report = (0, checks_1.preflight)(dir);
141
+ expect(check(report, 'host').ok).toBe(true);
142
+ expect(check(report, 'host').detail).toBe('github detected, gh available');
143
+ expect(report.status).toBe('ready');
144
+ });
145
+ it('is still ok:true (advisory only) when gitlab is detected but glab is not on PATH, and status stays ready', () => {
146
+ // The only thing "wrong" in this fixture is the missing `glab` — proving the
147
+ // advisory contract: it must not drag an otherwise-clean repo to `degraded`.
148
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
149
+ gitRepo(dir, 'https://gitlab.com/kodria/agentic-workflow.git');
150
+ mockExecSync.mockImplementation((() => {
151
+ throw new Error('not found');
152
+ }));
153
+ const report = (0, checks_1.preflight)(dir);
154
+ expect(check(report, 'host').ok).toBe(true);
155
+ expect(check(report, 'host').detail).toContain('glab not on PATH');
156
+ expect(check(report, 'host').remedy).toContain('glab');
157
+ expect(report.status).toBe('ready');
158
+ });
159
+ it('handles no origin remote gracefully — no throw, ok:true, minimal detail', () => {
160
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
161
+ // Not a git repo at all — the common case for `execFileSync` failing here.
162
+ const report = (0, checks_1.preflight)(dir);
163
+ expect(check(report, 'host').ok).toBe(true);
164
+ expect(check(report, 'host').detail).toBe('no git remote detected — PR/MR automation not applicable');
165
+ expect(check(report, 'host').remedy).toBeUndefined();
166
+ expect(report.status).toBe('ready');
167
+ });
168
+ it('handles a git repo with no origin remote configured gracefully', () => {
169
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
170
+ gitRepo(dir); // git init, no remote
171
+ const report = (0, checks_1.preflight)(dir);
172
+ expect(check(report, 'host').ok).toBe(true);
173
+ expect(check(report, 'host').detail).toContain('no git remote detected');
174
+ });
175
+ it('does not overclaim support for an unrecognized host', () => {
176
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
177
+ gitRepo(dir, 'git@bitbucket.org:kodria/agentic-workflow.git');
178
+ const report = (0, checks_1.preflight)(dir);
179
+ expect(check(report, 'host').ok).toBe(true);
180
+ expect(check(report, 'host').detail).toContain('not recognized');
181
+ expect(report.status).toBe('ready');
182
+ });
183
+ it('does not misclassify a GitHub Enterprise host whose repo NAME contains "gitlab"', () => {
184
+ // The bug: a bare `remote.includes('gitlab')` matches the full remote URL
185
+ // string, so an org/repo name containing "gitlab" false-positives even though
186
+ // the actual host is unrelated. Hostname must be extracted first and matched
187
+ // in isolation.
188
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
189
+ gitRepo(dir, 'git@github.enterprise.internal:kodria/gitlab-migration-tool.git');
190
+ const report = (0, checks_1.preflight)(dir);
191
+ expect(check(report, 'host').ok).toBe(true);
192
+ expect(check(report, 'host').detail).toContain('not recognized');
193
+ expect(report.status).toBe('ready');
194
+ });
195
+ it('does not misclassify a non-GitHub host whose repo NAME contains "github"', () => {
196
+ // Same class of bug on the github side: "something-github-tool" is a repo
197
+ // name, not the host.
198
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
199
+ gitRepo(dir, 'https://example.com/kodria/something-github-tool.git');
200
+ const report = (0, checks_1.preflight)(dir);
201
+ expect(check(report, 'host').ok).toBe(true);
202
+ expect(check(report, 'host').detail).toContain('not recognized');
203
+ expect(report.status).toBe('ready');
204
+ });
205
+ it('does not misclassify a GitHub Enterprise host whose SSH USERNAME is "gitlab"', () => {
206
+ // The related bug: the scheme-based regex captured everything between
207
+ // `scheme://` and the first `/`, including `userinfo@` — so an SSH username
208
+ // of "gitlab" leaked into the matched "host" string and false-positived the
209
+ // `.includes('gitlab')` check even though the real host is GitHub
210
+ // Enterprise. `new URL(...).hostname` must exclude userinfo entirely.
211
+ //
212
+ // Note: `github.company-internal.com` legitimately contains the substring
213
+ // "github.com" (from "company"), so — with the userinfo bug fixed — this
214
+ // correctly classifies as github (checkHost's own substring matching is a
215
+ // separate, pre-existing design, not part of this fix). The regression this
216
+ // test guards is that it must never again read as gitlab.
217
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
218
+ gitRepo(dir, 'ssh://gitlab@github.company-internal.com:22/team/repo.git');
219
+ mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
220
+ const report = (0, checks_1.preflight)(dir);
221
+ expect(check(report, 'host').ok).toBe(true);
222
+ expect(check(report, 'host').detail).toContain('github detected');
223
+ expect(check(report, 'host').detail).not.toContain('gitlab');
224
+ expect(report.status).toBe('ready');
225
+ });
226
+ it('does not misclassify a host whose injected credential/token contains "gitlab"', () => {
227
+ // A realistic CI credential-injection remote:
228
+ // `git remote set-url origin https://x-access-token:$TOKEN@host/...`. If the
229
+ // token or password happens to contain "gitlab", it must not leak into the
230
+ // matched host either.
231
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
232
+ gitRepo(dir, 'https://user:gitlab@example-host.com/org/repo.git');
233
+ const report = (0, checks_1.preflight)(dir);
234
+ expect(check(report, 'host').ok).toBe(true);
235
+ expect(check(report, 'host').detail).toContain('not recognized');
236
+ expect(report.status).toBe('ready');
237
+ });
238
+ it('does not misclassify an SCP-style remote with a second "@" in it', () => {
239
+ // `user@host:path` shorthand has no scheme for `URL` to parse, so it falls
240
+ // back to a regex. A second "@" (e.g. a malformed/adversarial remote) must
241
+ // not let a bogus "host@evil"-shaped capture slip past the colon check —
242
+ // the host-capture group excludes "@", so this fails to match at all and
243
+ // falls through to "unrecognized" rather than misclassifying as gitlab.
244
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
245
+ gitRepo(dir, 'user@github.com@gitlab.evil:org/repo.git');
246
+ const report = (0, checks_1.preflight)(dir);
247
+ expect(check(report, 'host').ok).toBe(true);
248
+ expect(check(report, 'host').detail).toContain('not recognized');
249
+ expect(report.status).toBe('ready');
250
+ });
251
+ it('still detects github.com over HTTPS', () => {
252
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
253
+ gitRepo(dir, 'https://github.com/org/repo.git');
254
+ mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
255
+ const report = (0, checks_1.preflight)(dir);
256
+ expect(check(report, 'host').detail).toContain('github detected');
257
+ });
258
+ it('still detects github.com over SSH shorthand', () => {
259
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
260
+ gitRepo(dir, 'git@github.com:org/repo.git');
261
+ mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
262
+ const report = (0, checks_1.preflight)(dir);
263
+ expect(check(report, 'host').detail).toContain('github detected');
264
+ });
265
+ it('still detects gitlab over HTTPS', () => {
266
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
267
+ gitRepo(dir, 'https://gitlab.example.com/org/repo.git');
268
+ mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
269
+ const report = (0, checks_1.preflight)(dir);
270
+ expect(check(report, 'host').detail).toContain('gitlab detected');
271
+ });
272
+ it('still detects gitlab over SSH shorthand', () => {
273
+ const dir = make({ manifest: { pack: 'generic', sensors: {} } });
274
+ gitRepo(dir, 'git@gitlab.example.com:org/repo.git');
275
+ mockExecSync.mockImplementation((() => { throw new Error('not found'); }));
276
+ const report = (0, checks_1.preflight)(dir);
277
+ expect(check(report, 'host').detail).toContain('gitlab detected');
278
+ });
279
+ });
115
280
  it('tells the operator not to hand a broken harness to an unattended run', () => {
116
281
  const out = (0, preflight_1.formatReport)({
117
282
  status: 'not_configured',
@@ -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({
@@ -5,7 +5,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const os_1 = __importDefault(require("os"));
7
7
  const path_1 = __importDefault(require("path"));
8
+ const child_process_1 = require("child_process");
8
9
  const paths_1 = require("../../src/core/paths");
10
+ jest.mock('child_process', () => ({ execSync: jest.fn() }));
11
+ const mockExecSync = child_process_1.execSync;
9
12
  describe('core/paths', () => {
10
13
  let origHome;
11
14
  let origAwmHome;
@@ -13,6 +16,7 @@ describe('core/paths', () => {
13
16
  beforeEach(() => {
14
17
  origHome = process.env.HOME;
15
18
  origAwmHome = process.env.AWM_HOME;
19
+ mockExecSync.mockReset();
16
20
  });
17
21
  afterEach(() => {
18
22
  if (origHome === undefined)
@@ -75,4 +79,34 @@ describe('core/paths', () => {
75
79
  (0, paths_1.warnIfUnsupportedPlatform)(log);
76
80
  expect(calls).toEqual([paths_1.WINDOWS_NATIVE_WARNING]);
77
81
  });
82
+ describe('resolveOnPath', () => {
83
+ it('uses `command -v` on POSIX and returns true when the binary resolves', () => {
84
+ setPlatform('linux');
85
+ mockExecSync.mockImplementation(((cmd) => {
86
+ if (cmd === 'command -v semgrep')
87
+ return Buffer.from('/usr/bin/semgrep');
88
+ throw new Error(`not found: ${cmd}`);
89
+ }));
90
+ expect((0, paths_1.resolveOnPath)('semgrep')).toBe(true);
91
+ });
92
+ it('returns false on POSIX when `command -v` fails to resolve the binary', () => {
93
+ setPlatform('linux');
94
+ mockExecSync.mockImplementation(() => { throw new Error('not found'); });
95
+ expect((0, paths_1.resolveOnPath)('semgrep')).toBe(false);
96
+ });
97
+ it('uses `where` on win32, not `command -v`', () => {
98
+ setPlatform('win32');
99
+ mockExecSync.mockImplementation(((cmd) => {
100
+ if (cmd === 'where semgrep')
101
+ return Buffer.from('C:\\tools\\semgrep.exe');
102
+ throw new Error(`not found: ${cmd}`);
103
+ }));
104
+ expect((0, paths_1.resolveOnPath)('semgrep')).toBe(true);
105
+ });
106
+ it('returns false on win32 when `where` cannot find the binary', () => {
107
+ setPlatform('win32');
108
+ mockExecSync.mockImplementation(() => { throw new Error('not found'); });
109
+ expect((0, paths_1.resolveOnPath)('semgrep')).toBe(false);
110
+ });
111
+ });
78
112
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-workflow-manager",
3
- "version": "3.9.0",
3
+ "version": "3.10.0",
4
4
  "main": "dist/src/index.js",
5
5
  "bin": {
6
6
  "awm": "./dist/src/index.js"