@indigoai-us/hq-cli 5.125.0 → 5.126.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
  3. package/assets/bot-workers/setup/skills/your-business.md +270 -0
  4. package/assets/bot-workers/setup/worker.yaml +189 -58
  5. package/dist/command-catalog.generated.d.ts +407 -4
  6. package/dist/command-catalog.generated.js +522 -4
  7. package/dist/command-registration-plan.d.ts +6 -0
  8. package/dist/command-registration-plan.js +1 -0
  9. package/dist/commands/bot.d.ts +10 -0
  10. package/dist/commands/bot.js +6 -0
  11. package/dist/commands/lanes.d.ts +75 -0
  12. package/dist/commands/lanes.js +2032 -0
  13. package/dist/commands/mesh.js +37 -9
  14. package/dist/commands/onboard-create-identity.d.ts +26 -0
  15. package/dist/commands/onboard-create-identity.js +23 -0
  16. package/dist/commands/onboard.js +13 -4
  17. package/dist/lib/bot/progress.d.ts +6 -0
  18. package/dist/lib/bot/progress.js +16 -0
  19. package/dist/lib/bot/runtime/grok.d.ts +30 -7
  20. package/dist/lib/bot/runtime/grok.js +86 -8
  21. package/dist/lib/bot/runtime/index.js +15 -8
  22. package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
  23. package/dist/lib/bot/runtime/messages-stream.js +49 -3
  24. package/dist/lib/lanes/accounts.d.ts +43 -0
  25. package/dist/lib/lanes/accounts.js +128 -0
  26. package/dist/lib/lanes/config.d.ts +56 -0
  27. package/dist/lib/lanes/config.js +351 -0
  28. package/dist/lib/lanes/errors.d.ts +39 -0
  29. package/dist/lib/lanes/errors.js +72 -0
  30. package/dist/lib/lanes/index.d.ts +19 -0
  31. package/dist/lib/lanes/index.js +12 -0
  32. package/dist/lib/lanes/liveness.d.ts +89 -0
  33. package/dist/lib/lanes/liveness.js +273 -0
  34. package/dist/lib/lanes/registry.d.ts +103 -0
  35. package/dist/lib/lanes/registry.js +247 -0
  36. package/dist/lib/lanes/store.d.ts +64 -0
  37. package/dist/lib/lanes/store.js +627 -0
  38. package/dist/lib/lanes/throttle.d.ts +89 -0
  39. package/dist/lib/lanes/throttle.js +297 -0
  40. package/dist/lib/lanes/types.d.ts +193 -0
  41. package/dist/lib/lanes/types.js +217 -0
  42. package/dist/lib/mesh/live/daemon/config.d.ts +27 -0
  43. package/dist/lib/mesh/live/daemon/config.js +75 -0
  44. package/dist/lib/mesh/live/daemon/index.d.ts +4 -3
  45. package/dist/lib/mesh/live/daemon/index.js +3 -3
  46. package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
  47. package/dist/lib/mesh/live/daemon/install.js +444 -2
  48. package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
  49. package/dist/lib/mesh/live/daemon/paths.js +2 -0
  50. package/dist/lib/scan-packages/index.js +17 -12
  51. package/dist/lib/work-context/config.d.ts +20 -0
  52. package/dist/lib/work-context/config.js +26 -5
  53. package/dist/lib/work-context/reconcile.js +28 -7
  54. package/dist/lib/work-context/windows-acl.d.ts +123 -0
  55. package/dist/lib/work-context/windows-acl.js +425 -0
  56. package/dist/utils/pack-contributions.js +5 -4
  57. package/package.json +1 -1
@@ -7,6 +7,7 @@ import * as path from "node:path";
7
7
  import { atomicWriteJson, ensureOwnerDir, resolveRealTarget } from "./atomic.js";
8
8
  import { DefaultCompanyUnavailableError, UnsafeConfigPathError, WorkContextError, } from "./errors.js";
9
9
  import { workContextConfigPath, workContextRoot } from "./paths.js";
10
+ import { assertWindowsOwnerOnlyAcl } from "./windows-acl.js";
10
11
  import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
11
12
  export const DEVICE_CONFIG_SCHEMA_VERSION = 1;
12
13
  const CONFIG_LOCK_TIMEOUT_MS = 5_000;
@@ -147,20 +148,40 @@ function sanitizeRepoCompanyMap(raw) {
147
148
  }
148
149
  return Object.keys(out).length > 0 ? out : null;
149
150
  }
150
- function assertSafeConfigPath(configPath) {
151
+ /**
152
+ * Refuse a config path that is a symlink (every platform) or writable by
153
+ * anyone other than the owner.
154
+ *
155
+ * - POSIX (darwin/linux/...): `mode & 0o022` must be clear. Unchanged.
156
+ * - win32: NTFS has no POSIX mode bits; Node reports 0666 for every regular
157
+ * file, so the mode test would reject every config (US-018). We read the
158
+ * real DACL instead (see windows-acl.ts). Unreadable DACL → documented
159
+ * reduced-guarantee path: one diagnostic line, no throw.
160
+ */
161
+ export function assertSafeConfigPath(configPath, io = {}) {
162
+ const platform = io.platform ?? process.platform;
163
+ const existsSync = io.existsSync ?? fs.existsSync;
164
+ const lstatSync = io.lstatSync ?? fs.lstatSync;
151
165
  const dir = path.dirname(configPath);
152
166
  try {
153
- if (fs.existsSync(dir)) {
154
- const lst = fs.lstatSync(dir);
167
+ if (existsSync(dir)) {
168
+ const lst = lstatSync(dir);
155
169
  if (lst.isSymbolicLink()) {
156
170
  throw new UnsafeConfigPathError(`Config directory is a symlink: ${dir}`);
157
171
  }
172
+ if (platform === "win32") {
173
+ assertWindowsOwnerOnlyAcl(dir, "directory", io.windowsAcl);
174
+ }
158
175
  }
159
- if (fs.existsSync(configPath)) {
160
- const lst = fs.lstatSync(configPath);
176
+ if (existsSync(configPath)) {
177
+ const lst = lstatSync(configPath);
161
178
  if (lst.isSymbolicLink()) {
162
179
  throw new UnsafeConfigPathError(`Config file is a symlink: ${configPath}`);
163
180
  }
181
+ if (platform === "win32") {
182
+ assertWindowsOwnerOnlyAcl(configPath, "file", io.windowsAcl);
183
+ return;
184
+ }
164
185
  const mode = lst.mode & 0o777;
165
186
  // Warn-level only for world-writable; refuse group/other write bits on read for safety.
166
187
  if (mode & 0o022) {
@@ -701,17 +701,37 @@ export async function reconcileObservation(obs, deps) {
701
701
  if (delivered.ok) {
702
702
  markOutboxAcked(outboxOp.operationId, deps.root, delivered.receiptId, nowFn);
703
703
  writeAckedRegisterState(outboxOp, deps.root, delivered.receiptId, nowFn);
704
- // Persist binding episode on success for bound registrations.
705
- if (registerClassification === "bound") {
704
+ // Company attribution write for EVERY acked register that resolved a
705
+ // company — not only bound sessions. A company-only ack (needs_project /
706
+ // needs_task / unresolved) must still project companyUid/companySlug and
707
+ // the true computed classification into durable session state; otherwise
708
+ // the emitter and mesh presence lose the tenant the server already
709
+ // acknowledged. projectId / taskId are written only when genuinely
710
+ // resolved, and a fresh bindingEpisodeId only when the session is bound —
711
+ // never fabricated on a partial path. (writeAckedRegisterState above may
712
+ // have already recorded a receipt-derived episode for a resolved partial
713
+ // binding; the default merge preserves that when the binding still matches.)
714
+ if (resolvedCompany.uid) {
715
+ const bound = registerClassification === "bound";
716
+ // A server ack of the company makes it authoritative: the durable status
717
+ // is at least needs_project (company known, no project), needs_task
718
+ // (project, no task), or bound (project + task) — mirroring
719
+ // acknowledgedStatus() in state.ts. Never downgrade an acked company to a
720
+ // pre-threshold "unresolved" projection; that is what dropped the tenant.
721
+ const ackedClassification = projectId && taskId
722
+ ? "bound"
723
+ : projectId
724
+ ? "needs_task"
725
+ : "needs_project";
706
726
  writeSessionState(mergeLocalSessionFields({
707
727
  contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
708
728
  sessionId,
709
729
  companyUid: resolvedCompany.uid,
710
730
  companySlug: resolvedCompany.slug,
711
- contextStatus: "bound",
712
- projectId,
713
- taskId,
714
- bindingEpisodeId: delivered.receiptId,
731
+ contextStatus: bound ? "bound" : ackedClassification,
732
+ ...(projectId ? { projectId } : {}),
733
+ ...(taskId ? { taskId } : {}),
734
+ ...(bound ? { bindingEpisodeId: delivered.receiptId } : {}),
715
735
  updatedAt: nowFn().toISOString(),
716
736
  startedAt,
717
737
  ...(typeof toolWrites === "number" ? { toolWrites } : {}),
@@ -719,7 +739,8 @@ export async function reconcileObservation(obs, deps) {
719
739
  ...transcriptFields,
720
740
  },
721
741
  // Merge against the just-written projection rather than the stale
722
- // snapshot from before reconciliation (retains device confidence).
742
+ // pre-reconcile snapshot, so device confidence and any receipt
743
+ // attribution survive.
723
744
  readSessionState(sessionId, deps.root) ?? stateWrite), deps.root);
724
745
  }
725
746
  const kind = registerClassification === "bound"
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Windows DACL check for the work-context config (US-018).
3
+ *
4
+ * NTFS has no POSIX mode bits: Node reports `mode` 0666 for every regular
5
+ * file on win32, so the POSIX `mode & 0o022` test in config.ts can never pass
6
+ * there. Instead of skipping the check on Windows, this module reads the real
7
+ * DACL and refuses the config when any principal other than the current user,
8
+ * SYSTEM, Administrators, or the owner is granted write/modify/full access.
9
+ *
10
+ * How the DACL is read (no native dependency):
11
+ * `icacls <path> /save <tmpfile>` writes the DACL as an SDDL string
12
+ * (`D:AI(A;ID;FA;;;SY)(A;ID;FA;;;BA)(A;ID;FA;;;S-1-5-21-...)`). SDDL carries
13
+ * SIDs or well-known SID abbreviations, never display names, so the check is
14
+ * independent of the OS language (VORDEFINIERT\Administratoren is still BA /
15
+ * S-1-5-32-544). The current user's SID comes from `whoami /user`. Display
16
+ * names are only resolved (via PowerShell) for the error message.
17
+ *
18
+ * Trust is decided by SID: S-1-5-18 (SYSTEM), S-1-5-32-544 (Administrators),
19
+ * S-1-3-0 (CREATOR OWNER), S-1-3-4 (OWNER RIGHTS), the current user's SID and
20
+ * the owner's SID when known.
21
+ *
22
+ * Reduced-guarantee path (explicit, documented): the check does NOT throw and
23
+ * instead emits one stderr diagnostic that the config is unprotected on this
24
+ * platform when, and only when, the DACL cannot be evaluated:
25
+ * - icacls is missing, fails, or its output has no SDDL line (unreadable);
26
+ * - an ACE grants write to a principal whose SID we cannot resolve (an SDDL
27
+ * abbreviation this module does not know), or carries a rights token we
28
+ * cannot decode, or the current user's SID is unknown and the grant is to
29
+ * a non-well-known SID (unresolvable).
30
+ * A DACL that parses and grants write to a resolved SID outside the trusted
31
+ * set always throws UnsafeConfigPathError. Nothing else falls back.
32
+ */
33
+ export interface SddlAce {
34
+ /** ACE type token, e.g. `A` (allow) or `D` (deny). */
35
+ type: string;
36
+ /** ACE flag string, e.g. `OICIID`. */
37
+ flags: string;
38
+ /** Rights field as written, e.g. `FA`, `0x1200a9`, `CCDCLCSWRPWPRC`. */
39
+ rights: string;
40
+ /** Account field as written: full SID or two-letter abbreviation. */
41
+ account: string;
42
+ }
43
+ export type AclReadResult = {
44
+ readable: true;
45
+ sddl: string;
46
+ } | {
47
+ readable: false;
48
+ reason: string;
49
+ };
50
+ export interface WindowsAclCheckOptions {
51
+ /** Current user SID (defaults to `whoami /user`). `null` = unknown. */
52
+ currentUserSid?: string | null;
53
+ /** Owner SID when known (icacls /save does not include the owner). */
54
+ ownerSid?: string | null;
55
+ /** DACL reader; injectable for tests. Defaults to `icacls <path> /save`. */
56
+ readAcl?: (targetPath: string) => AclReadResult;
57
+ /** SID → display name for error messages; injectable for tests. */
58
+ resolveSidName?: (sid: string) => string | null;
59
+ /** Diagnostic sink for the reduced-guarantee path (defaults to stderr). */
60
+ diagnostic?: (line: string) => void;
61
+ }
62
+ export type AclVerdict = {
63
+ kind: "safe";
64
+ } | {
65
+ kind: "unsafe";
66
+ sid: string;
67
+ rights: string;
68
+ } | {
69
+ kind: "unresolvable";
70
+ reason: string;
71
+ };
72
+ export declare const SID_SYSTEM = "S-1-5-18";
73
+ export declare const SID_ADMINISTRATORS = "S-1-5-32-544";
74
+ export declare const SID_CREATOR_OWNER = "S-1-3-0";
75
+ export declare const SID_OWNER_RIGHTS = "S-1-3-4";
76
+ /** Resolve an SDDL account field to a SID, or null when unknown. */
77
+ export declare function sddlAccountToSid(account: string): string | null;
78
+ /** Any of these bits lets the holder change the file or replace it. */
79
+ export declare const WRITE_MASK: number;
80
+ /** Decode an SDDL rights field to an access mask, or null when undecodable. */
81
+ export declare function sddlRightsToMask(rights: string): number | null;
82
+ /**
83
+ * Parse the DACL portion of an SDDL string into ACEs. Accepts a full security
84
+ * descriptor string (`O:...G:...D:...S:...`) or just the `D:` section.
85
+ * Returns null when no DACL section is present or an ACE is malformed.
86
+ */
87
+ export declare function parseSddlDacl(sddl: string): SddlAce[] | null;
88
+ /**
89
+ * Parse the file `icacls <path> /save <file>` writes: UTF-16LE (optional BOM),
90
+ * CRLF, alternating `<name>` / `<sddl>` lines. Returns the SDDL of the first
91
+ * entry (the target itself), or an unreadable result.
92
+ */
93
+ export declare function parseIcaclsSaveOutput(raw: string | Buffer): AclReadResult;
94
+ /**
95
+ * Absolute path of a System32 tool. PATH lookup is not safe here: Git Bash and
96
+ * MSYS environments put a coreutils `whoami` ahead of Windows' `whoami.exe`,
97
+ * which does not understand `/user` and would make the current user SID look
98
+ * unknown on every run.
99
+ */
100
+ export declare function system32Tool(exe: string): string;
101
+ export declare function readAclWithIcaclsSave(targetPath: string): AclReadResult;
102
+ /** Current user's SID via `whoami /user /fo csv /nh`; null when unavailable. */
103
+ export declare function readCurrentUserSid(): string | null;
104
+ /** Best-effort SID → account name (only used to build error messages). */
105
+ export declare function resolveSidNameWithPowerShell(sid: string): string | null;
106
+ /**
107
+ * Evaluate parsed ACEs against the trusted SID set.
108
+ * - `unsafe`: an allow ACE grants a write bit to a resolved, untrusted SID.
109
+ * - `unresolvable`: a write-granting ACE whose SID or rights cannot be decoded,
110
+ * or whose SID is a per-machine/domain SID while the current user is unknown.
111
+ * - `safe`: everything else (deny ACEs and read-only grants are ignored).
112
+ */
113
+ export declare function evaluateDacl(aces: SddlAce[], opts: Pick<WindowsAclCheckOptions, "currentUserSid" | "ownerSid">): AclVerdict;
114
+ /**
115
+ * Assert the Windows DACL of `targetPath` grants write only to trusted
116
+ * principals. Throws UnsafeConfigPathError naming the offending principal.
117
+ * Unreadable or unresolvable DACL → reduced-guarantee diagnostic (once per
118
+ * path), no throw. See the module header for the exact conditions.
119
+ */
120
+ export declare function assertWindowsOwnerOnlyAcl(targetPath: string, kind: "file" | "directory", opts?: WindowsAclCheckOptions): void;
121
+ /** Test hook: forget which paths already emitted the reduced-guarantee line. */
122
+ export declare function resetWindowsAclDiagnostics(): void;
123
+ //# sourceMappingURL=windows-acl.d.ts.map
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Windows DACL check for the work-context config (US-018).
3
+ *
4
+ * NTFS has no POSIX mode bits: Node reports `mode` 0666 for every regular
5
+ * file on win32, so the POSIX `mode & 0o022` test in config.ts can never pass
6
+ * there. Instead of skipping the check on Windows, this module reads the real
7
+ * DACL and refuses the config when any principal other than the current user,
8
+ * SYSTEM, Administrators, or the owner is granted write/modify/full access.
9
+ *
10
+ * How the DACL is read (no native dependency):
11
+ * `icacls <path> /save <tmpfile>` writes the DACL as an SDDL string
12
+ * (`D:AI(A;ID;FA;;;SY)(A;ID;FA;;;BA)(A;ID;FA;;;S-1-5-21-...)`). SDDL carries
13
+ * SIDs or well-known SID abbreviations, never display names, so the check is
14
+ * independent of the OS language (VORDEFINIERT\Administratoren is still BA /
15
+ * S-1-5-32-544). The current user's SID comes from `whoami /user`. Display
16
+ * names are only resolved (via PowerShell) for the error message.
17
+ *
18
+ * Trust is decided by SID: S-1-5-18 (SYSTEM), S-1-5-32-544 (Administrators),
19
+ * S-1-3-0 (CREATOR OWNER), S-1-3-4 (OWNER RIGHTS), the current user's SID and
20
+ * the owner's SID when known.
21
+ *
22
+ * Reduced-guarantee path (explicit, documented): the check does NOT throw and
23
+ * instead emits one stderr diagnostic that the config is unprotected on this
24
+ * platform when, and only when, the DACL cannot be evaluated:
25
+ * - icacls is missing, fails, or its output has no SDDL line (unreadable);
26
+ * - an ACE grants write to a principal whose SID we cannot resolve (an SDDL
27
+ * abbreviation this module does not know), or carries a rights token we
28
+ * cannot decode, or the current user's SID is unknown and the grant is to
29
+ * a non-well-known SID (unresolvable).
30
+ * A DACL that parses and grants write to a resolved SID outside the trusted
31
+ * set always throws UnsafeConfigPathError. Nothing else falls back.
32
+ */
33
+ import * as fs from "node:fs";
34
+ import * as os from "node:os";
35
+ import * as path from "node:path";
36
+ import { spawnSync } from "node:child_process";
37
+ import { UnsafeConfigPathError } from "./errors.js";
38
+ // --- SIDs ---------------------------------------------------------------
39
+ export const SID_SYSTEM = "S-1-5-18";
40
+ export const SID_ADMINISTRATORS = "S-1-5-32-544";
41
+ export const SID_CREATOR_OWNER = "S-1-3-0";
42
+ export const SID_OWNER_RIGHTS = "S-1-3-4";
43
+ /** SDDL well-known account abbreviations (subset relevant to file DACLs). */
44
+ const SDDL_SID_ABBREVIATIONS = {
45
+ SY: SID_SYSTEM,
46
+ BA: SID_ADMINISTRATORS,
47
+ BU: "S-1-5-32-545", // Users
48
+ BG: "S-1-5-32-546", // Guests
49
+ PU: "S-1-5-32-547", // Power Users
50
+ WD: "S-1-1-0", // Everyone
51
+ AU: "S-1-5-11", // Authenticated Users
52
+ IU: "S-1-5-4", // Interactive
53
+ NU: "S-1-5-2", // Network
54
+ SU: "S-1-5-6", // Service
55
+ AN: "S-1-5-7", // Anonymous
56
+ LS: "S-1-5-19", // Local Service
57
+ NS: "S-1-5-20", // Network Service
58
+ CO: SID_CREATOR_OWNER,
59
+ CG: "S-1-3-1", // Creator Group
60
+ OW: SID_OWNER_RIGHTS,
61
+ RC: "S-1-5-12", // Restricted Code
62
+ AC: "S-1-15-2-1", // All application packages
63
+ DA: "S-1-5-21-<domain>-512", // Domain Admins (domain-relative; never trusted)
64
+ DU: "S-1-5-21-<domain>-513", // Domain Users
65
+ LA: "S-1-5-21-<domain>-500", // Local Administrator account
66
+ LG: "S-1-5-21-<domain>-501", // Local Guest account
67
+ };
68
+ /** Resolve an SDDL account field to a SID, or null when unknown. */
69
+ export function sddlAccountToSid(account) {
70
+ const a = account.trim();
71
+ if (/^S-1-\d+(-\d+)+$/i.test(a))
72
+ return a.toUpperCase();
73
+ const abbr = SDDL_SID_ABBREVIATIONS[a.toUpperCase()];
74
+ if (!abbr || abbr.includes("<domain>"))
75
+ return null;
76
+ return abbr;
77
+ }
78
+ // --- rights -------------------------------------------------------------
79
+ const FILE_WRITE_DATA = 0x2;
80
+ const FILE_APPEND_DATA = 0x4;
81
+ const FILE_WRITE_EA = 0x10;
82
+ const FILE_DELETE_CHILD = 0x40;
83
+ const FILE_WRITE_ATTRIBUTES = 0x100;
84
+ const DELETE = 0x10000;
85
+ const WRITE_DAC = 0x40000;
86
+ const WRITE_OWNER = 0x80000;
87
+ const GENERIC_ALL = 0x10000000;
88
+ const GENERIC_WRITE = 0x40000000;
89
+ /** Any of these bits lets the holder change the file or replace it. */
90
+ export const WRITE_MASK = FILE_WRITE_DATA |
91
+ FILE_APPEND_DATA |
92
+ FILE_WRITE_EA |
93
+ FILE_DELETE_CHILD |
94
+ FILE_WRITE_ATTRIBUTES |
95
+ DELETE |
96
+ WRITE_DAC |
97
+ WRITE_OWNER |
98
+ GENERIC_ALL |
99
+ GENERIC_WRITE;
100
+ /** SDDL rights tokens → access mask (generic + file-specific + DS bits). */
101
+ const SDDL_RIGHTS_TOKENS = {
102
+ GA: GENERIC_ALL,
103
+ GR: 0x80000000,
104
+ GW: GENERIC_WRITE,
105
+ GX: 0x20000000,
106
+ FA: 0x1f01ff,
107
+ FR: 0x120089,
108
+ FW: 0x120116,
109
+ FX: 0x1200a0,
110
+ KA: 0xf003f,
111
+ KR: 0x20019,
112
+ KW: 0x20006,
113
+ KX: 0x20019,
114
+ RC: 0x20000,
115
+ SD: DELETE,
116
+ WD: WRITE_DAC,
117
+ WO: WRITE_OWNER,
118
+ RP: 0x10,
119
+ WP: 0x20,
120
+ CC: 0x1,
121
+ DC: 0x2,
122
+ LC: 0x4,
123
+ SW: 0x8,
124
+ LO: 0x80,
125
+ DT: 0x40,
126
+ CR: 0x100,
127
+ };
128
+ /** Decode an SDDL rights field to an access mask, or null when undecodable. */
129
+ export function sddlRightsToMask(rights) {
130
+ const r = rights.trim();
131
+ if (!r)
132
+ return 0;
133
+ if (/^0x[0-9a-f]+$/i.test(r))
134
+ return Number.parseInt(r, 16) >>> 0;
135
+ if (/^\d+$/.test(r))
136
+ return Number.parseInt(r, 10) >>> 0;
137
+ if (r.length % 2 !== 0)
138
+ return null;
139
+ let mask = 0;
140
+ for (let i = 0; i < r.length; i += 2) {
141
+ const bits = SDDL_RIGHTS_TOKENS[r.slice(i, i + 2).toUpperCase()];
142
+ if (bits === undefined)
143
+ return null;
144
+ mask = (mask | bits) >>> 0;
145
+ }
146
+ return mask;
147
+ }
148
+ // --- parsing ------------------------------------------------------------
149
+ /**
150
+ * Parse the DACL portion of an SDDL string into ACEs. Accepts a full security
151
+ * descriptor string (`O:...G:...D:...S:...`) or just the `D:` section.
152
+ * Returns null when no DACL section is present or an ACE is malformed.
153
+ */
154
+ export function parseSddlDacl(sddl) {
155
+ const text = sddl.trim();
156
+ // Colons never occur inside an ACE or a SID, so section markers are unambiguous.
157
+ const dIdx = text.indexOf("D:");
158
+ if (dIdx < 0)
159
+ return null;
160
+ let body = text.slice(dIdx + 2);
161
+ const sIdx = body.indexOf("S:");
162
+ if (sIdx >= 0)
163
+ body = body.slice(0, sIdx);
164
+ // Strip DACL control flags (P, AI, AR, NO_ACCESS_CONTROL) before the first ACE.
165
+ const firstAce = body.indexOf("(");
166
+ const flags = firstAce < 0 ? body : body.slice(0, firstAce);
167
+ if (!/^[A-Z_]*$/i.test(flags.trim()))
168
+ return null;
169
+ if (firstAce < 0)
170
+ return [];
171
+ const aceText = body.slice(firstAce);
172
+ const aces = [];
173
+ const aceRe = /\(([^()]*)\)/g;
174
+ let consumed = 0;
175
+ let m;
176
+ while ((m = aceRe.exec(aceText)) !== null) {
177
+ if (m.index !== consumed)
178
+ return null; // junk between ACEs
179
+ consumed = m.index + m[0].length;
180
+ const fields = m[1].split(";");
181
+ if (fields.length < 6)
182
+ return null;
183
+ const [type, aceFlags, rights, , , account] = fields;
184
+ if (!type || !account)
185
+ return null;
186
+ aces.push({
187
+ type: type.trim().toUpperCase(),
188
+ flags: aceFlags.trim().toUpperCase(),
189
+ rights: rights.trim(),
190
+ account: account.trim(),
191
+ });
192
+ }
193
+ if (consumed !== aceText.trim().length)
194
+ return null;
195
+ return aces;
196
+ }
197
+ /**
198
+ * Parse the file `icacls <path> /save <file>` writes: UTF-16LE (optional BOM),
199
+ * CRLF, alternating `<name>` / `<sddl>` lines. Returns the SDDL of the first
200
+ * entry (the target itself), or an unreadable result.
201
+ */
202
+ export function parseIcaclsSaveOutput(raw) {
203
+ let text;
204
+ if (Buffer.isBuffer(raw)) {
205
+ const hasLeBom = raw.length >= 2 && raw[0] === 0xff && raw[1] === 0xfe;
206
+ text = raw.toString("utf16le", hasLeBom ? 2 : 0);
207
+ }
208
+ else {
209
+ text = raw;
210
+ }
211
+ text = text.replace(/^\uFEFF/, "");
212
+ const lines = text.split(/\r?\n/).map((l) => l.trim());
213
+ const sddl = lines.find((l) => /^(O:|G:|D:|S:)/.test(l));
214
+ if (!sddl) {
215
+ return { readable: false, reason: "no SDDL line in icacls /save output" };
216
+ }
217
+ return { readable: true, sddl };
218
+ }
219
+ // --- platform readers ---------------------------------------------------
220
+ /**
221
+ * Absolute path of a System32 tool. PATH lookup is not safe here: Git Bash and
222
+ * MSYS environments put a coreutils `whoami` ahead of Windows' `whoami.exe`,
223
+ * which does not understand `/user` and would make the current user SID look
224
+ * unknown on every run.
225
+ */
226
+ export function system32Tool(exe) {
227
+ const winDir = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
228
+ return path.join(winDir, "System32", exe);
229
+ }
230
+ export function readAclWithIcaclsSave(targetPath) {
231
+ const stamp = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
232
+ const saveFile = path.join(os.tmpdir(), `hq-acl-${stamp}.txt`);
233
+ try {
234
+ const res = spawnSync(system32Tool("icacls.exe"), [targetPath, "/save", saveFile], {
235
+ encoding: "utf8",
236
+ windowsHide: true,
237
+ timeout: 10_000,
238
+ });
239
+ if (res.error) {
240
+ return { readable: false, reason: `icacls unavailable: ${res.error.message}` };
241
+ }
242
+ if (res.status !== 0) {
243
+ const stderr = String(res.stderr ?? "").trim();
244
+ return {
245
+ readable: false,
246
+ reason: `icacls exited ${String(res.status)}${stderr ? `: ${stderr}` : ""}`,
247
+ };
248
+ }
249
+ let raw;
250
+ try {
251
+ raw = fs.readFileSync(saveFile);
252
+ }
253
+ catch (err) {
254
+ return { readable: false, reason: `icacls /save wrote nothing: ${err.message}` };
255
+ }
256
+ return parseIcaclsSaveOutput(raw);
257
+ }
258
+ catch (err) {
259
+ return { readable: false, reason: `icacls failed: ${err.message}` };
260
+ }
261
+ finally {
262
+ try {
263
+ fs.rmSync(saveFile, { force: true });
264
+ }
265
+ catch {
266
+ // best effort
267
+ }
268
+ }
269
+ }
270
+ let cachedCurrentUserSid;
271
+ /** Current user's SID via `whoami /user /fo csv /nh`; null when unavailable. */
272
+ export function readCurrentUserSid() {
273
+ if (cachedCurrentUserSid !== undefined)
274
+ return cachedCurrentUserSid;
275
+ try {
276
+ const res = spawnSync(system32Tool("whoami.exe"), ["/user", "/fo", "csv", "/nh"], {
277
+ encoding: "utf8",
278
+ windowsHide: true,
279
+ timeout: 10_000,
280
+ });
281
+ const m = /"(S-1-\d+(?:-\d+)+)"/i.exec(String(res.stdout ?? ""));
282
+ cachedCurrentUserSid = res.status === 0 && m ? m[1].toUpperCase() : null;
283
+ }
284
+ catch {
285
+ cachedCurrentUserSid = null;
286
+ }
287
+ return cachedCurrentUserSid;
288
+ }
289
+ /** Best-effort SID → account name (only used to build error messages). */
290
+ export function resolveSidNameWithPowerShell(sid) {
291
+ if (!/^S-1-\d+(-\d+)+$/i.test(sid))
292
+ return null;
293
+ try {
294
+ const script = `(New-Object System.Security.Principal.SecurityIdentifier('${sid}'))` +
295
+ `.Translate([System.Security.Principal.NTAccount]).Value`;
296
+ const res = spawnSync(system32Tool("WindowsPowerShell/v1.0/powershell.exe"), ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { encoding: "utf8", windowsHide: true, timeout: 15_000 });
297
+ const out = String(res.stdout ?? "").trim();
298
+ return res.status === 0 && out ? out : null;
299
+ }
300
+ catch {
301
+ return null;
302
+ }
303
+ }
304
+ // --- evaluation ---------------------------------------------------------
305
+ /**
306
+ * Evaluate parsed ACEs against the trusted SID set.
307
+ * - `unsafe`: an allow ACE grants a write bit to a resolved, untrusted SID.
308
+ * - `unresolvable`: a write-granting ACE whose SID or rights cannot be decoded,
309
+ * or whose SID is a per-machine/domain SID while the current user is unknown.
310
+ * - `safe`: everything else (deny ACEs and read-only grants are ignored).
311
+ */
312
+ export function evaluateDacl(aces, opts) {
313
+ const trusted = new Set([SID_SYSTEM, SID_ADMINISTRATORS, SID_CREATOR_OWNER, SID_OWNER_RIGHTS]);
314
+ if (opts.currentUserSid)
315
+ trusted.add(opts.currentUserSid.toUpperCase());
316
+ if (opts.ownerSid)
317
+ trusted.add(opts.ownerSid.toUpperCase());
318
+ for (const ace of aces) {
319
+ if (ace.type !== "A")
320
+ continue; // deny / audit / object ACEs never grant
321
+ if (ace.flags.includes("IO"))
322
+ continue; // inherit-only: no effect on this object
323
+ const mask = sddlRightsToMask(ace.rights);
324
+ if (mask === null) {
325
+ return {
326
+ kind: "unresolvable",
327
+ reason: `cannot decode rights "${ace.rights}" for ${ace.account}`,
328
+ };
329
+ }
330
+ if ((mask & WRITE_MASK) === 0)
331
+ continue;
332
+ const sid = sddlAccountToSid(ace.account);
333
+ if (!sid) {
334
+ return { kind: "unresolvable", reason: `cannot resolve principal "${ace.account}" to a SID` };
335
+ }
336
+ if (trusted.has(sid))
337
+ continue;
338
+ if (!opts.currentUserSid && /^S-1-5-21-/.test(sid)) {
339
+ return {
340
+ kind: "unresolvable",
341
+ reason: `current user SID unknown; cannot tell whether ${sid} is the current user`,
342
+ };
343
+ }
344
+ return { kind: "unsafe", sid, rights: ace.rights };
345
+ }
346
+ return { kind: "safe" };
347
+ }
348
+ const reducedGuaranteeReported = new Set();
349
+ /**
350
+ * Per-process memo of non-throwing verdicts, keyed by the object's identity
351
+ * and NTFS ChangeTime (Node `ctimeMs`), which advances whenever the DACL is
352
+ * modified. Spawning icacls costs ~100-200ms; hooks may read the config many
353
+ * times per process. Unsafe verdicts are never cached.
354
+ */
355
+ const verdictCache = new Map();
356
+ function cacheKey(targetPath) {
357
+ try {
358
+ const st = fs.lstatSync(targetPath);
359
+ return { ino: st.ino, ctimeMs: st.ctimeMs };
360
+ }
361
+ catch {
362
+ return null;
363
+ }
364
+ }
365
+ /**
366
+ * Assert the Windows DACL of `targetPath` grants write only to trusted
367
+ * principals. Throws UnsafeConfigPathError naming the offending principal.
368
+ * Unreadable or unresolvable DACL → reduced-guarantee diagnostic (once per
369
+ * path), no throw. See the module header for the exact conditions.
370
+ */
371
+ export function assertWindowsOwnerOnlyAcl(targetPath, kind, opts = {}) {
372
+ const readAcl = opts.readAcl ?? readAclWithIcaclsSave;
373
+ const resolveSidName = opts.resolveSidName ?? resolveSidNameWithPowerShell;
374
+ const diagnostic = opts.diagnostic ?? ((line) => process.stderr.write(`${line}\n`));
375
+ const currentUserSid = opts.currentUserSid === undefined ? readCurrentUserSid() : opts.currentUserSid;
376
+ const reducedGuarantee = (reason) => {
377
+ // Reduced-guarantee path: see module header. Deliberately not a throw.
378
+ if (reducedGuaranteeReported.has(targetPath))
379
+ return;
380
+ reducedGuaranteeReported.add(targetPath);
381
+ diagnostic(`work-context: could not evaluate Windows ACL for ${kind} ${targetPath} (${reason}); ` +
382
+ "config is unprotected on this platform (reduced guarantee)");
383
+ };
384
+ const key = cacheKey(targetPath);
385
+ const cached = key ? verdictCache.get(targetPath) : undefined;
386
+ if (key && cached && cached.ino === key.ino && cached.ctimeMs === key.ctimeMs)
387
+ return;
388
+ const acl = readAcl(targetPath);
389
+ if (!acl.readable) {
390
+ reducedGuarantee(acl.reason);
391
+ if (key)
392
+ verdictCache.set(targetPath, key);
393
+ return;
394
+ }
395
+ const aces = parseSddlDacl(acl.sddl);
396
+ if (aces === null) {
397
+ reducedGuarantee(`unparseable SDDL "${acl.sddl}"`);
398
+ if (key)
399
+ verdictCache.set(targetPath, key);
400
+ return;
401
+ }
402
+ const verdict = evaluateDacl(aces, { currentUserSid, ownerSid: opts.ownerSid ?? null });
403
+ if (verdict.kind === "safe") {
404
+ if (key)
405
+ verdictCache.set(targetPath, key);
406
+ return;
407
+ }
408
+ if (verdict.kind === "unresolvable") {
409
+ reducedGuarantee(verdict.reason);
410
+ if (key)
411
+ verdictCache.set(targetPath, key);
412
+ return;
413
+ }
414
+ const name = resolveSidName(verdict.sid);
415
+ const who = name ? `${name} (${verdict.sid})` : verdict.sid;
416
+ throw new UnsafeConfigPathError(`Config ${kind} ${targetPath} is writable by ${who} [rights ${verdict.rights}] ` +
417
+ "(expected owner-only: current user, SYSTEM, Administrators)");
418
+ }
419
+ /** Test hook: forget which paths already emitted the reduced-guarantee line. */
420
+ export function resetWindowsAclDiagnostics() {
421
+ reducedGuaranteeReported.clear();
422
+ verdictCache.clear();
423
+ cachedCurrentUserSid = undefined;
424
+ }
425
+ //# sourceMappingURL=windows-acl.js.map
@@ -199,10 +199,11 @@ export function linkStatus(link, packDir) {
199
199
  catch {
200
200
  return 'foreign';
201
201
  }
202
- // scan-packages.sh writes the symlink target as the absolute `src` path.
203
- // Canonicalize both sides so `/var` vs `/private/var` (macOS) and symlink
204
- // aliases classify as ours; optionally accept stale targets that still resolve
205
- // inside this pack's directory.
202
+ // scan-packages writes the symlink target as a path relative to the host
203
+ // directory (`path.relative(dirname(dst), src)`). Older trees still carry
204
+ // absolute `src` strings; canonicalize both so those, `/var` vs `/private/var`
205
+ // (macOS), and symlink aliases classify as ours; optionally accept stale
206
+ // targets that still resolve inside this pack's directory.
206
207
  const resolvedTarget = path.resolve(path.dirname(link.dst), target);
207
208
  if (pointsAtDeclaredPayload(link, resolvedTarget)) {
208
209
  return fs.existsSync(link.src) ? 'live' : 'broken';