@indigoai-us/hq-cli 5.341.1 → 5.341.3

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/assets/hqd/windows-pipe-host.ps1 +282 -0
  3. package/dist/bin/hq-auth-refresh.d.ts +1 -1
  4. package/dist/bin/hq-auth-refresh.js +11 -2
  5. package/dist/commands/billing-sponsor.js +20 -1
  6. package/dist/commands/core-derive-trigger-facts-native.d.ts +2 -0
  7. package/dist/commands/core-derive-trigger-facts-native.js +92 -25
  8. package/dist/commands/core-migrate-policy-triggers-native.d.ts +1 -0
  9. package/dist/commands/core-migrate-policy-triggers-native.js +26 -12
  10. package/dist/commands/doctor.js +2 -2
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +17 -1
  13. package/dist/lib/aws-sdk-node-warning-output.js +2 -3
  14. package/dist/lib/billing/plan-lock.js +19 -2
  15. package/dist/lib/bot/owner-context.d.ts +2 -0
  16. package/dist/lib/bot/owner-context.js +19 -1
  17. package/dist/lib/daemon/flag-monitor.d.ts +23 -0
  18. package/dist/lib/daemon/flag-monitor.js +99 -0
  19. package/dist/lib/daemon/flag.d.ts +2 -0
  20. package/dist/lib/daemon/flag.js +10 -1
  21. package/dist/lib/daemon/protocol.d.ts +26 -3
  22. package/dist/lib/daemon/protocol.js +9 -5
  23. package/dist/lib/daemon/run.js +14 -16
  24. package/dist/lib/daemon/server.d.ts +23 -2
  25. package/dist/lib/daemon/server.js +397 -52
  26. package/dist/lib/daemon/windows-pipe-host.d.ts +17 -0
  27. package/dist/lib/daemon/windows-pipe-host.js +218 -0
  28. package/dist/lib/diagnostics.d.ts +9 -0
  29. package/dist/lib/diagnostics.js +12 -0
  30. package/dist/lib/doctor/checks/anywhere.d.ts +6 -1
  31. package/dist/lib/doctor/checks/anywhere.js +131 -13
  32. package/dist/lib/doctor/json-output.d.ts +2 -0
  33. package/dist/lib/doctor/json-output.js +1 -0
  34. package/dist/lib/doctor/registry.js +1 -3
  35. package/dist/lib/doctor/types.d.ts +4 -0
  36. package/dist/utils/package-use-lease.d.ts +4 -0
  37. package/dist/utils/package-use-lease.js +28 -0
  38. package/dist/utils/run-bundled-script.d.ts +2 -0
  39. package/dist/utils/run-bundled-script.js +10 -1
  40. package/dist/utils/vault-api.js +12 -12
  41. package/docs/anywhere-runtime.md +49 -5
  42. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.341.3] — 2026-10-03
6
+ - Preserve policy-trigger parity for Windows PATH resolution, filesystem paths, and frontmatter parsing.
7
+ - Report unexpected company-resolve, plan-limit body and late owner-context failures that a bare catch used to hide. The report prints only with `--verbose` or `HQ_DEBUG` set.
8
+ - Enable hqd on Windows through a bundled PowerShell named-pipe host that applies and verifies a current-user-only DACL, while refusing startup if the security check fails.
9
+ - `hq doctor` now reports hqd reachability, runtime flag freshness and fallback
10
+ reasons for the current directory. The same fields are available with
11
+ `hq doctor --json`, including while the runtime flag is off.
12
+ - Hardened Unix hqd socket startup to reject symlinks anywhere in the socket directory path and unexpected socket-path entries, replace only a stale socket owned by the current user, and verify user-only directory and socket permissions. The default socket now lives under the private `~/.hq/daemon/` directory.
13
+
14
+ ## [5.341.2] — 2026-10-03
15
+
16
+ - Preserve the original integrations response read error instead of returning a consumed response.
17
+
18
+ - The CLI no longer starts while a confirmed Desktop update is in progress after a lease I/O error.
19
+ - hqd refreshes `hq-anywhere-runtime` every 30 seconds, fails closed when the last successful read is older than 60 seconds, and clears its in-memory stdin login when the flag turns off.
20
+ - Hardened hqd company resolution so unreadable registries and registry links to companies missing from the configured HQ manifest fall back to personal context. Added adversarial coverage for symlinks, nested repositories, clones, path normalization, worktrees, and session bindings.
21
+
5
22
  ## [5.341.1] — 2026-10-03
6
23
  - Match native policy frontmatter and report bytes to the bundled shell on Windows, including paths, diagnostics, and JSON newlines.
7
24
  - Keep session-journal printable paths byte-compatible with the shell on Windows.
@@ -0,0 +1,282 @@
1
+ $ErrorActionPreference = 'Stop'
2
+ [Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false)
3
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
4
+ try {
5
+ Add-Type -AssemblyName System.IO.Pipes.AccessControl -ErrorAction SilentlyContinue
6
+ } catch {
7
+ # Windows PowerShell 5.1 ships the access-control types in its framework assemblies.
8
+ }
9
+
10
+ $source = @'
11
+ using System;
12
+ using System.Collections;
13
+ using System.Collections.Concurrent;
14
+ using System.IO;
15
+ using System.IO.Pipes;
16
+ using System.Linq;
17
+ using System.Reflection;
18
+ using System.Security.AccessControl;
19
+ using System.Security.Principal;
20
+ using System.Text;
21
+ using System.Threading;
22
+ using System.Threading.Tasks;
23
+ using Microsoft.Win32.SafeHandles;
24
+ using System.Runtime.InteropServices;
25
+
26
+ public static class HqdWindowsPipeHost
27
+ {
28
+ private static readonly object OutputLock = new object();
29
+ private static readonly ConcurrentDictionary<int, NamedPipeServerStream> Connections = new ConcurrentDictionary<int, NamedPipeServerStream>();
30
+ private static int NextConnectionId;
31
+ private static volatile bool Stopping;
32
+ private static readonly ManualResetEventSlim AcceptStarted = new ManualResetEventSlim(false);
33
+ private static string PipeName;
34
+ private static SecurityIdentifier UserSid;
35
+ private static PipeSecurity Security;
36
+
37
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
38
+ private static extern bool GetNamedPipeClientComputerName(SafePipeHandle pipe, StringBuilder computerName, uint length);
39
+
40
+ private static void Emit(string frame)
41
+ {
42
+ lock (OutputLock)
43
+ {
44
+ Console.Out.WriteLine(frame);
45
+ Console.Out.Flush();
46
+ }
47
+ }
48
+
49
+ private static string B64(string value)
50
+ {
51
+ return Convert.ToBase64String(Encoding.UTF8.GetBytes(value));
52
+ }
53
+
54
+ private static void EmitFatal(Exception error)
55
+ {
56
+ Emit("FATAL\t" + B64(error.GetBaseException().Message));
57
+ }
58
+
59
+ private static PipeSecurity BuildSecurity(SecurityIdentifier sid)
60
+ {
61
+ PipeSecurity security = new PipeSecurity();
62
+ security.SetAccessRuleProtection(true, false);
63
+ security.AddAccessRule(new PipeAccessRule(sid, PipeAccessRights.FullControl, AccessControlType.Allow));
64
+ return security;
65
+ }
66
+
67
+ private static void VerifySecurity(NamedPipeServerStream pipe)
68
+ {
69
+ PipeSecurity actual = pipe.GetAccessControl();
70
+ AuthorizationRuleCollection rules = actual.GetAccessRules(true, true, typeof(SecurityIdentifier));
71
+ if (rules.Count != 1)
72
+ throw new InvalidOperationException("created pipe DACL has an unexpected number of ACEs");
73
+ PipeAccessRule rule = rules[0] as PipeAccessRule;
74
+ if (rule == null || rule.AccessControlType != AccessControlType.Allow ||
75
+ !UserSid.Equals(rule.IdentityReference) || rule.PipeAccessRights != PipeAccessRights.FullControl ||
76
+ rule.InheritanceFlags != InheritanceFlags.None || rule.PropagationFlags != PropagationFlags.None ||
77
+ rule.IsInherited)
78
+ throw new InvalidOperationException("created pipe DACL is not exactly current-user full control");
79
+ if (!actual.AreAccessRulesProtected)
80
+ throw new InvalidOperationException("created pipe DACL is not protected from inherited ACEs");
81
+ }
82
+
83
+ private static NamedPipeServerStream CreatePipe(bool first)
84
+ {
85
+ PipeOptions options = PipeOptions.Asynchronous;
86
+ string[] names = Enum.GetNames(typeof(PipeOptions));
87
+ if (first && names.Contains("FirstPipeInstance"))
88
+ options |= (PipeOptions)Enum.Parse(typeof(PipeOptions), "FirstPipeInstance");
89
+
90
+ int instances = 254;
91
+ Type aclFactory = Type.GetType("System.IO.Pipes.NamedPipeServerStreamAcl, System.IO.Pipes.AccessControl", false);
92
+ if (aclFactory != null)
93
+ {
94
+ MethodInfo method = aclFactory.GetMethods(BindingFlags.Public | BindingFlags.Static)
95
+ .Where(m => m.Name == "Create")
96
+ .FirstOrDefault(m => {
97
+ ParameterInfo[] p = m.GetParameters();
98
+ return p.Length >= 8 && p[0].ParameterType == typeof(string) && p[7].ParameterType == typeof(PipeSecurity);
99
+ });
100
+ if (method != null)
101
+ {
102
+ ParameterInfo[] parameters = method.GetParameters();
103
+ object[] args = new object[parameters.Length];
104
+ args[0] = PipeName;
105
+ args[1] = PipeDirection.InOut;
106
+ args[2] = instances;
107
+ args[3] = PipeTransmissionMode.Byte;
108
+ args[4] = options;
109
+ args[5] = 65536;
110
+ args[6] = 65536;
111
+ args[7] = Security;
112
+ for (int i = 8; i < args.Length; i++)
113
+ args[i] = parameters[i].DefaultValue;
114
+ return (NamedPipeServerStream)method.Invoke(null, args);
115
+ }
116
+ }
117
+
118
+ ConstructorInfo constructor = typeof(NamedPipeServerStream).GetConstructor(new Type[] {
119
+ typeof(string), typeof(PipeDirection), typeof(int), typeof(PipeTransmissionMode),
120
+ typeof(PipeOptions), typeof(int), typeof(int), typeof(PipeSecurity)
121
+ });
122
+ if (constructor == null)
123
+ throw new PlatformNotSupportedException("this PowerShell runtime has no ACL-capable named pipe constructor");
124
+ return (NamedPipeServerStream)constructor.Invoke(new object[] {
125
+ PipeName, PipeDirection.InOut, instances, PipeTransmissionMode.Byte,
126
+ options, 65536, 65536, Security
127
+ });
128
+ }
129
+
130
+ private static bool IsLocalClient(NamedPipeServerStream pipe)
131
+ {
132
+ StringBuilder clientName = new StringBuilder(256);
133
+ uint bufferLengthBytes = (uint)(clientName.Capacity * 2);
134
+ if (!GetNamedPipeClientComputerName(pipe.SafePipeHandle, clientName, bufferLengthBytes))
135
+ {
136
+ // ERROR_PIPE_LOCAL (229), not ERROR_PIPE_NOT_CONNECTED (233),
137
+ // means the client connected through the local NPFS path.
138
+ int error = Marshal.GetLastWin32Error();
139
+ if (error == 229) return true;
140
+ throw new InvalidOperationException("could not verify the named-pipe client computer (Win32 error " + error + ")");
141
+ }
142
+ // A successful lookup identifies a remote SMB client.
143
+ Emit("REJECT\t" + B64("remote named-pipe client computer " + clientName));
144
+ return false;
145
+ }
146
+
147
+ private static void Relay(int id, NamedPipeServerStream pipe)
148
+ {
149
+ try
150
+ {
151
+ byte[] buffer = new byte[32768];
152
+ while (!Stopping && pipe.IsConnected)
153
+ {
154
+ int count = pipe.Read(buffer, 0, buffer.Length);
155
+ if (count <= 0) break;
156
+ string data = Convert.ToBase64String(buffer, 0, count);
157
+ Emit("DATA\t" + id + "\t" + data);
158
+ }
159
+ }
160
+ catch (ObjectDisposedException) { }
161
+ catch (IOException) { }
162
+ finally
163
+ {
164
+ NamedPipeServerStream ignored;
165
+ Connections.TryRemove(id, out ignored);
166
+ try { pipe.Dispose(); } catch { }
167
+ Emit("CLOSE\t" + id);
168
+ }
169
+ }
170
+
171
+ private static void AcceptLoop(NamedPipeServerStream first)
172
+ {
173
+ NamedPipeServerStream listener = first;
174
+ try
175
+ {
176
+ AcceptStarted.Wait();
177
+ while (!Stopping)
178
+ {
179
+ listener.WaitForConnection();
180
+ if (!IsLocalClient(listener))
181
+ {
182
+ listener.Dispose();
183
+ listener = CreatePipe(false);
184
+ VerifySecurity(listener);
185
+ continue;
186
+ }
187
+
188
+ NamedPipeServerStream connected = listener;
189
+ listener = CreatePipe(false);
190
+ VerifySecurity(listener);
191
+ int id = Interlocked.Increment(ref NextConnectionId);
192
+ Connections[id] = connected;
193
+ Emit("OPEN\t" + id);
194
+ Task.Run(() => Relay(id, connected));
195
+ }
196
+ }
197
+ catch (ObjectDisposedException error)
198
+ {
199
+ if (!Stopping)
200
+ {
201
+ EmitFatal(error);
202
+ Stopping = true;
203
+ foreach (NamedPipeServerStream pipe in Connections.Values)
204
+ try { pipe.Dispose(); } catch { }
205
+ try { listener.Dispose(); } catch { }
206
+ Environment.ExitCode = 1;
207
+ }
208
+ }
209
+ catch (Exception error)
210
+ {
211
+ EmitFatal(error);
212
+ Stopping = true;
213
+ foreach (NamedPipeServerStream pipe in Connections.Values)
214
+ try { pipe.Dispose(); } catch { }
215
+ try { listener.Dispose(); } catch { }
216
+ Environment.ExitCode = 1;
217
+ }
218
+ }
219
+
220
+ private static void ReadControl()
221
+ {
222
+ string line;
223
+ while (!Stopping && (line = Console.In.ReadLine()) != null)
224
+ {
225
+ string[] fields = line.Split('\t');
226
+ if (fields[0] == "START")
227
+ {
228
+ AcceptStarted.Set();
229
+ continue;
230
+ }
231
+ if (fields.Length < 2) continue;
232
+ int id;
233
+ if (!Int32.TryParse(fields[1], out id)) continue;
234
+ NamedPipeServerStream pipe;
235
+ if (!Connections.TryGetValue(id, out pipe)) continue;
236
+ try
237
+ {
238
+ if (fields[0] == "WRITE" && fields.Length == 3)
239
+ {
240
+ byte[] bytes = Convert.FromBase64String(fields[2]);
241
+ pipe.Write(bytes, 0, bytes.Length);
242
+ pipe.Flush();
243
+ }
244
+ else if (fields[0] == "CLOSE")
245
+ {
246
+ pipe.Dispose();
247
+ }
248
+ }
249
+ catch (ObjectDisposedException) { }
250
+ catch (IOException) { }
251
+ }
252
+ Stopping = true;
253
+ foreach (NamedPipeServerStream pipe in Connections.Values)
254
+ try { pipe.Dispose(); } catch { }
255
+ }
256
+
257
+ public static void Run(string pipeName)
258
+ {
259
+ PipeName = pipeName;
260
+ UserSid = WindowsIdentity.GetCurrent().User;
261
+ if (UserSid == null) throw new InvalidOperationException("current Windows user SID is unavailable");
262
+ Security = BuildSecurity(UserSid);
263
+ NamedPipeServerStream first = CreatePipe(true);
264
+ VerifySecurity(first);
265
+ string sddl = first.GetAccessControl().GetSecurityDescriptorSddlForm(AccessControlSections.Access);
266
+ Emit("READY\t" + UserSid.Value + "\t" + B64(sddl));
267
+ Task accept = Task.Run(() => AcceptLoop(first));
268
+ ReadControl();
269
+ Stopping = true;
270
+ try { first.Dispose(); } catch { }
271
+ try { accept.Wait(1000); } catch { }
272
+ }
273
+ }
274
+ '@
275
+
276
+ try {
277
+ Add-Type -TypeDefinition $source -ErrorAction Stop
278
+ [HqdWindowsPipeHost]::Run($env:HQD_PIPE_NAME)
279
+ } catch {
280
+ [Console]::Error.WriteLine($_.Exception.GetBaseException().Message)
281
+ exit 1
282
+ }
@@ -18,6 +18,6 @@ type RefreshSession = (options?: {
18
18
  minValiditySeconds?: number;
19
19
  }) => ReturnType<typeof refreshCachedSession>;
20
20
  export declare function runAuthRefresh(refreshSession?: RefreshSession): Promise<number>;
21
- export declare function startAuthRefresh(acquireLease?: typeof acquireCurrentPackageUseLease): Promise<number>;
21
+ export declare function startAuthRefresh(acquireLease?: typeof acquireCurrentPackageUseLease, updateRequestPending?: () => boolean): Promise<number>;
22
22
  export {};
23
23
  //# sourceMappingURL=hq-auth-refresh.d.ts.map
@@ -13,7 +13,7 @@
13
13
  // MUST be first: guard the Node version before any dependency that needs a
14
14
  // Node 20+ API (e.g. util.styleText) or a newer native ABI is evaluated.
15
15
  import "../node-preflight.js";
16
- import { acquireCurrentPackageUseLease } from "../utils/package-use-lease.js";
16
+ import { acquireCurrentPackageUseLease, currentPackageUseUpdatePending, } from "../utils/package-use-lease.js";
17
17
  import "../node-network-compat.js";
18
18
  import { isMainModule } from "../utils/is-main-module.js";
19
19
  const MIN_VALIDITY_FLAG = "--min-validity-seconds";
@@ -93,7 +93,7 @@ function packageUseErrorClass(error) {
93
93
  }
94
94
  return typeof value.name === "string" ? value.name : "unknown";
95
95
  }
96
- export async function startAuthRefresh(acquireLease = acquireCurrentPackageUseLease) {
96
+ export async function startAuthRefresh(acquireLease = acquireCurrentPackageUseLease, updateRequestPending = currentPackageUseUpdatePending) {
97
97
  try {
98
98
  acquireLease();
99
99
  }
@@ -102,6 +102,15 @@ export async function startAuthRefresh(acquireLease = acquireCurrentPackageUseLe
102
102
  process.stderr.write(`hq-auth-refresh: ${error instanceof Error ? error.message : "The HQ CLI package is being updated"}\n`);
103
103
  return 1;
104
104
  }
105
+ try {
106
+ if (updateRequestPending()) {
107
+ process.stderr.write("hq-auth-refresh: The HQ CLI package is being updated; retry after the update finishes\n");
108
+ return 1;
109
+ }
110
+ }
111
+ catch {
112
+ // A failed pending-request probe preserves the existing fail-open behavior.
113
+ }
105
114
  process.stderr.write(`hq-auth-refresh: package-use lease unavailable (${packageUseErrorClass(error)}); continuing without lease\n`);
106
115
  }
107
116
  const { initSentry } = await import("../sentry.js");
@@ -27,7 +27,9 @@
27
27
  * a feature that simply is not turned on.
28
28
  */
29
29
  import chalk from "chalk";
30
+ import { diagnosticsEnabled } from "../lib/diagnostics.js";
30
31
  import { ensureCognitoToken } from "../utils/cognito-session.js";
32
+ import { isCompanySelectionError } from "../utils/company-selection-error.js";
31
33
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
32
34
  /** The request kind this command files. Matches hq-pro's contract. */
33
35
  export const PLAN_RESOURCE = "plan";
@@ -45,6 +47,20 @@ export const HELD_FOR_CARD_SENTENCE = "Your company said yes and is adding a car
45
47
  class SponsorError extends Error {
46
48
  expected = true;
47
49
  }
50
+ function isExpectedCompanyResolveFailure(error) {
51
+ if (isCompanySelectionError(error))
52
+ return true;
53
+ return (typeof error === "object" &&
54
+ error !== null &&
55
+ "expected" in error &&
56
+ error.expected === true);
57
+ }
58
+ function noteSponsorCompanyUnresolved(error) {
59
+ if (isExpectedCompanyResolveFailure(error) || !diagnosticsEnabled())
60
+ return;
61
+ const errorName = error instanceof Error ? error.name : "Error";
62
+ console.error(`billing-sponsor company resolve failed errorName=${errorName}`);
63
+ }
48
64
  function fail(err) {
49
65
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
50
66
  process.exit(1);
@@ -185,7 +201,10 @@ export function registerBillingSponsorCommands(billing) {
185
201
  // this feature is for often belongs to no company at all, and they
186
202
  // still need to be told what to do next.
187
203
  const named = opts.company ?? billing.opts().company;
188
- const companyUid = await getCompanyUid(token, named).catch(() => null);
204
+ const companyUid = await getCompanyUid(token, named).catch((error) => {
205
+ noteSponsorCompanyUnresolved(error);
206
+ return null;
207
+ });
189
208
  // The admin queue is the richer view. A non-admin is refused it, so we
190
209
  // fall back to the member SELF-READ — a different route with a
191
210
  // different answer: hq-pro returns ONE status for that person and that
@@ -15,6 +15,8 @@ export type DeriveTriggerRunResult = {
15
15
  stdout: Buffer;
16
16
  stderr: Buffer;
17
17
  };
18
+ export declare function resolveWindowsFilesystemPath(candidate: string, cwd: string, resolveRealpath?: (value: string) => string, isDirectory?: (value: string) => boolean): string | undefined;
19
+ export declare function commandOnPath(command: string, env: Record<string, string | undefined>, platform?: NodeJS.Platform): boolean;
18
20
  /** Run the native handler contract, retaining exact bytes for differential tests. */
19
21
  export declare function runDeriveTriggerFacts(options: DeriveTriggerRunOptions): DeriveTriggerRunResult;
20
22
  export declare function runDeriveTriggerFactsFast(argv: readonly string[], stdin: string, cwd: string, hqRoot: string | undefined): number;
@@ -4,7 +4,6 @@ import * as path from "node:path";
4
4
  import { runBundledScript } from "../utils/run-bundled-script.js";
5
5
  import { isByteOrientedLocale } from "../lib/core-utils/byte-oriented-locale.js";
6
6
  export const DERIVE_TRIGGER_FACTS_ASSET = "core/scripts/derive-trigger-facts.sh";
7
- const TEXT_FACT_AWK = "core/scripts/lib/trigger-fact-text.awk";
8
7
  const TRANSCRIPT_TAIL_MAX_BYTES = 1_048_576;
9
8
  function effectiveEnvironment(options) {
10
9
  return {
@@ -62,13 +61,51 @@ function globSegmentMatches(pattern, value) {
62
61
  return false;
63
62
  }
64
63
  }
64
+ export function resolveWindowsFilesystemPath(candidate, cwd, resolveRealpath = (value) => fs.realpathSync.native(value), isDirectory = (value) => fs.statSync(value).isDirectory()) {
65
+ const value = candidate || ".";
66
+ // Node's win32 path helpers collapse `link/..` lexically before the OS can
67
+ // follow `link`. Bash walks the path component by component, so resolve a
68
+ // symlink before applying each parent component while keeping the emitted
69
+ // glob word untouched.
70
+ // Git Bash exposes mounted Windows drives as /c/... while Node's win32
71
+ // path parser treats that spelling as a path rooted at the current drive.
72
+ // Convert only the drive-mount prefix before probing the filesystem.
73
+ const windowsValue = value.replace(/^\/([A-Za-z])(?:\/|$)/, (_match, drive) => `${drive.toUpperCase()}:\\`);
74
+ const absolute = path.win32.isAbsolute(windowsValue);
75
+ const parsed = path.win32.parse(absolute ? windowsValue : cwd);
76
+ let current = absolute ? parsed.root : cwd;
77
+ const rest = absolute ? windowsValue.slice(parsed.root.length) : windowsValue;
78
+ for (const segment of rest.split(/[\\/]+/)) {
79
+ if (!segment || segment === ".")
80
+ continue;
81
+ if (segment === "..") {
82
+ try {
83
+ current = resolveRealpath(current);
84
+ if (!isDirectory(current))
85
+ return undefined;
86
+ }
87
+ catch {
88
+ return undefined;
89
+ }
90
+ current = path.win32.dirname(current);
91
+ }
92
+ else {
93
+ current = path.win32.join(current, segment);
94
+ }
95
+ }
96
+ return current;
97
+ }
65
98
  function filesystemPath(candidate, cwd) {
66
99
  const value = candidate || ".";
67
- return path.isAbsolute(value) ? value : `${cwd}${path.sep}${value}`;
100
+ if (process.platform !== "win32") {
101
+ return path.isAbsolute(value) ? value : `${cwd}${path.sep}${value}`;
102
+ }
103
+ return resolveWindowsFilesystemPath(value, cwd);
68
104
  }
69
105
  function isDirectoryPath(candidate, cwd) {
70
106
  try {
71
- return fs.statSync(filesystemPath(candidate, cwd)).isDirectory();
107
+ const resolved = filesystemPath(candidate, cwd);
108
+ return resolved !== undefined && fs.statSync(resolved).isDirectory();
72
109
  }
73
110
  catch {
74
111
  return false;
@@ -76,7 +113,10 @@ function isDirectoryPath(candidate, cwd) {
76
113
  }
77
114
  function pathEntryExists(candidate, cwd) {
78
115
  try {
79
- fs.lstatSync(filesystemPath(candidate, cwd));
116
+ const resolved = filesystemPath(candidate, cwd);
117
+ if (resolved === undefined)
118
+ return false;
119
+ fs.lstatSync(resolved);
80
120
  return true;
81
121
  }
82
122
  catch {
@@ -86,16 +126,17 @@ function pathEntryExists(candidate, cwd) {
86
126
  function expandBashGlob(token, cwd) {
87
127
  if (!hasGlobPattern(token))
88
128
  return [token];
89
- const absolute = token.startsWith(path.sep);
90
- const trailingSlash = token.endsWith(path.sep);
91
- const parts = token.split(path.sep).filter((part, index) => !(absolute && index === 0));
129
+ // Bash emits slash-separated words even when running under Git Bash on Windows.
130
+ const absolute = token.startsWith("/");
131
+ const trailingSlash = token.endsWith("/");
132
+ const parts = token.split("/").filter((part, index) => !(absolute && index === 0));
92
133
  if (trailingSlash)
93
134
  parts.pop();
94
- let candidates = [absolute ? path.sep : ""];
135
+ let candidates = [absolute ? "/" : ""];
95
136
  let matchedPattern = false;
96
137
  for (const [index, part] of parts.entries()) {
97
138
  if (!hasGlobPattern(part)) {
98
- candidates = candidates.map((parent) => !parent ? part : parent === path.sep ? `${path.sep}${part}` : `${parent}${path.sep}${part}`);
139
+ candidates = candidates.map((parent) => !parent ? part : parent === "/" ? `/${part}` : `${parent}/${part}`);
99
140
  if (matchedPattern) {
100
141
  const needsDirectory = index < parts.length - 1 || trailingSlash;
101
142
  candidates = candidates.filter((candidate) => needsDirectory ? isDirectoryPath(candidate, cwd) : pathEntryExists(candidate, cwd));
@@ -106,6 +147,8 @@ function expandBashGlob(token, cwd) {
106
147
  const expanded = [];
107
148
  for (const parent of candidates) {
108
149
  const directory = filesystemPath(parent || ".", cwd);
150
+ if (directory === undefined)
151
+ continue;
109
152
  let names;
110
153
  try {
111
154
  names = fs.readdirSync(directory).sort();
@@ -118,7 +161,7 @@ function expandBashGlob(token, cwd) {
118
161
  continue;
119
162
  if (!globSegmentMatches(part, name))
120
163
  continue;
121
- expanded.push(!parent ? name : parent === path.sep ? `${path.sep}${name}` : `${parent}${path.sep}${name}`);
164
+ expanded.push(!parent ? name : parent === "/" ? `/${name}` : `${parent}/${name}`);
122
165
  }
123
166
  }
124
167
  if (expanded.length === 0)
@@ -128,7 +171,7 @@ function expandBashGlob(token, cwd) {
128
171
  if (trailingSlash) {
129
172
  candidates = candidates
130
173
  .filter((candidate) => isDirectoryPath(candidate, cwd))
131
- .map((candidate) => candidate.endsWith(path.sep) ? candidate : `${candidate}${path.sep}`)
174
+ .map((candidate) => candidate.endsWith("/") ? candidate : `${candidate}/`)
132
175
  .sort();
133
176
  }
134
177
  return matchedPattern && candidates.length > 0 ? candidates : [token];
@@ -136,16 +179,27 @@ function expandBashGlob(token, cwd) {
136
179
  function bashUnquotedWords(facts, cwd) {
137
180
  return facts.join(" ").split(/[\t\n ]+/).filter(Boolean).flatMap((word) => expandBashGlob(word, cwd));
138
181
  }
139
- function commandOnPath(command, env) {
182
+ export function commandOnPath(command, env, platform = process.platform) {
140
183
  const pathValue = env.PATH ?? "";
184
+ const extensions = platform === "win32"
185
+ ? ["", ...(env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)]
186
+ : [""];
141
187
  for (const directory of pathValue.split(path.delimiter)) {
142
- const candidate = path.join(directory || process.cwd(), command);
143
- try {
144
- fs.accessSync(candidate, fs.constants.X_OK);
145
- return true;
146
- }
147
- catch {
148
- // PATH probes intentionally ignore missing and inaccessible entries.
188
+ for (const extension of extensions) {
189
+ const candidate = path.join(directory || process.cwd(), `${command}${extension}`);
190
+ try {
191
+ if (platform === "win32") {
192
+ if (fs.statSync(candidate).isFile())
193
+ return true;
194
+ }
195
+ else {
196
+ fs.accessSync(candidate, fs.constants.X_OK);
197
+ return true;
198
+ }
199
+ }
200
+ catch {
201
+ // PATH probes intentionally ignore missing and inaccessible entries.
202
+ }
149
203
  }
150
204
  }
151
205
  return false;
@@ -185,10 +239,12 @@ function parsePayload(raw) {
185
239
  parsed = JSON.parse(raw);
186
240
  }
187
241
  catch {
188
- throw new ShellFallbackRequired("malformed hook JSON");
242
+ // The shell redirects jq's parse diagnostic and leaves every field empty.
243
+ return emptyPayload();
189
244
  }
190
245
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
191
- throw new ShellFallbackRequired("unexpected hook payload shape");
246
+ // jq's object indexing fails quietly through the same redirected pipeline.
247
+ return emptyPayload();
192
248
  }
193
249
  const record = parsed;
194
250
  const toolResponse = jqField(record.tool_response);
@@ -207,6 +263,12 @@ function parsePayload(raw) {
207
263
  }
208
264
  return fields;
209
265
  }
266
+ function emptyPayload() {
267
+ return {
268
+ tool: "", command: "", runInBackground: "", toolResponse: "", prompt: "",
269
+ cwd: "", sessionId: "", transcriptPath: "",
270
+ };
271
+ }
210
272
  function matchTextFacts(rawText, env) {
211
273
  const text = localeLower(rawText, env);
212
274
  const facts = [];
@@ -391,9 +453,8 @@ function computeOutput(options) {
391
453
  const withAssistantIntent = args[1] === "--with-assistant-intent";
392
454
  if (args.length === 2 && !withAssistantIntent)
393
455
  throw new ShellFallbackRequired("unsupported argv");
394
- if (!["PreToolUse", "PostToolUse", "UserPromptSubmit", "AssistantIntent"].includes(event)) {
395
- throw new ShellFallbackRequired("unsupported event");
396
- }
456
+ // The shell accepts unknown event names and emits the baseline `always`
457
+ // fact (plus any static session facts); it does not validate the event.
397
458
  const paired = withAssistantIntent && (event === "PreToolUse" || event === "UserPromptSubmit");
398
459
  const env = effectiveEnvironment(options);
399
460
  const jqAvailable = options.jqAvailable ?? commandOnPath("jq", env);
@@ -465,6 +526,7 @@ function shellFallback(options) {
465
526
  args: [...options.argv],
466
527
  cwd: options.hqRoot,
467
528
  hqRoot: options.hqRoot,
529
+ msysScriptPath: true,
468
530
  stdin: options.stdin,
469
531
  captureOutput: true,
470
532
  env,
@@ -487,12 +549,17 @@ function missingSupportLibrary(hqRoot) {
487
549
  ],
488
550
  ];
489
551
  for (const [relativePath, message] of required) {
552
+ let descriptor;
490
553
  try {
491
- fs.accessSync(path.join(hqRoot, relativePath), fs.constants.R_OK);
554
+ descriptor = fs.openSync(path.join(hqRoot, relativePath), "r");
492
555
  }
493
556
  catch {
494
557
  return message;
495
558
  }
559
+ finally {
560
+ if (descriptor !== undefined)
561
+ fs.closeSync(descriptor);
562
+ }
496
563
  }
497
564
  return undefined;
498
565
  }
@@ -12,6 +12,7 @@ export type MigratePolicyDependencies = {
12
12
  error?: Error;
13
13
  };
14
14
  };
15
+ export declare function executableInPath(name: string, env: NodeJS.ProcessEnv, fileSystem: typeof fs, platform?: NodeJS.Platform): boolean;
15
16
  export declare function runMigratePolicyTriggers(args: readonly string[], deps: MigratePolicyDependencies): {
16
17
  code: number;
17
18
  stdout: string;