@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.
- package/CHANGELOG.md +118 -0
- package/assets/bot-workers/setup/skills/joining-a-team.md +125 -0
- package/assets/bot-workers/setup/skills/your-business.md +270 -0
- package/assets/bot-workers/setup/worker.yaml +189 -58
- package/dist/command-catalog.generated.d.ts +407 -4
- package/dist/command-catalog.generated.js +522 -4
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/bot.d.ts +10 -0
- package/dist/commands/bot.js +6 -0
- package/dist/commands/lanes.d.ts +75 -0
- package/dist/commands/lanes.js +2032 -0
- package/dist/commands/mesh.js +37 -9
- package/dist/commands/onboard-create-identity.d.ts +26 -0
- package/dist/commands/onboard-create-identity.js +23 -0
- package/dist/commands/onboard.js +13 -4
- package/dist/lib/bot/progress.d.ts +6 -0
- package/dist/lib/bot/progress.js +16 -0
- package/dist/lib/bot/runtime/grok.d.ts +30 -7
- package/dist/lib/bot/runtime/grok.js +86 -8
- package/dist/lib/bot/runtime/index.js +15 -8
- package/dist/lib/bot/runtime/messages-stream.d.ts +12 -3
- package/dist/lib/bot/runtime/messages-stream.js +49 -3
- package/dist/lib/lanes/accounts.d.ts +43 -0
- package/dist/lib/lanes/accounts.js +128 -0
- package/dist/lib/lanes/config.d.ts +56 -0
- package/dist/lib/lanes/config.js +351 -0
- package/dist/lib/lanes/errors.d.ts +39 -0
- package/dist/lib/lanes/errors.js +72 -0
- package/dist/lib/lanes/index.d.ts +19 -0
- package/dist/lib/lanes/index.js +12 -0
- package/dist/lib/lanes/liveness.d.ts +89 -0
- package/dist/lib/lanes/liveness.js +273 -0
- package/dist/lib/lanes/registry.d.ts +103 -0
- package/dist/lib/lanes/registry.js +247 -0
- package/dist/lib/lanes/store.d.ts +64 -0
- package/dist/lib/lanes/store.js +627 -0
- package/dist/lib/lanes/throttle.d.ts +89 -0
- package/dist/lib/lanes/throttle.js +297 -0
- package/dist/lib/lanes/types.d.ts +193 -0
- package/dist/lib/lanes/types.js +217 -0
- package/dist/lib/mesh/live/daemon/config.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/config.js +75 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -3
- package/dist/lib/mesh/live/daemon/index.js +3 -3
- package/dist/lib/mesh/live/daemon/install.d.ts +143 -1
- package/dist/lib/mesh/live/daemon/install.js +444 -2
- package/dist/lib/mesh/live/daemon/paths.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/paths.js +2 -0
- package/dist/lib/scan-packages/index.js +17 -12
- package/dist/lib/work-context/config.d.ts +20 -0
- package/dist/lib/work-context/config.js +26 -5
- package/dist/lib/work-context/reconcile.js +28 -7
- package/dist/lib/work-context/windows-acl.d.ts +123 -0
- package/dist/lib/work-context/windows-acl.js +425 -0
- package/dist/utils/pack-contributions.js +5 -4
- 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
|
-
|
|
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 (
|
|
154
|
-
const lst =
|
|
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 (
|
|
160
|
-
const lst =
|
|
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
|
-
//
|
|
705
|
-
|
|
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
|
|
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
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
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';
|