mcp-pacemaker 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +452 -0
- package/CONTRIBUTING.md +222 -0
- package/LICENSE +21 -0
- package/README.md +621 -0
- package/SECURITY.md +120 -0
- package/THIRD_PARTY_NOTICES.txt +366 -0
- package/autostart/windows/launcher.vbs +11 -0
- package/autostart/windows/register-task.ps1 +63 -0
- package/bin/cli.mjs +880 -0
- package/bin/config-checks.mjs +62 -0
- package/bin/logs.mjs +465 -0
- package/bin/mcp-bridge.mjs +2162 -0
- package/bin/pooling-batch-scheduler.mjs +129 -0
- package/bin/pooling-batches.mjs +270 -0
- package/bin/pooling-config.mjs +577 -0
- package/bin/pooling-editor.mjs +128 -0
- package/bin/pooling-errors.mjs +23 -0
- package/bin/pooling-execution.mjs +104 -0
- package/bin/pooling-files.mjs +541 -0
- package/bin/pooling-trace.mjs +218 -0
- package/bin/pooling-writer.mjs +242 -0
- package/bin/prewarm.mjs +233 -0
- package/bin/server-metrics.mjs +69 -0
- package/bin/service-control.mjs +124 -0
- package/bin/shared-sessions.mjs +1083 -0
- package/bin/top.mjs +115 -0
- package/bin/update-check.mjs +153 -0
- package/bin/windows/PoolingSecurityHelper.build.json +42 -0
- package/bin/windows/PoolingSecurityHelper.exe +0 -0
- package/bin/windows/src/AssemblyInfo.cs +11 -0
- package/bin/windows/src/PoolingNativeFiles.cs +678 -0
- package/bin/windows/src/PoolingSecurityHelper.cs +178 -0
- package/bin/windows/src/PoolingSecurityReader.cs +50 -0
- package/docs/shared-sessions.md +181 -0
- package/examples/servers.example.json +31 -0
- package/package.json +77 -0
- package/supervisor/bridge-child.mjs +14 -0
- package/supervisor/supervise.mjs +172 -0
- package/supervisor/supervise.ps1 +23 -0
- package/supervisor/supervise.sh +11 -0
- package/tools/windows-security-helper/README.md +238 -0
- package/tools/windows-security-helper/build.ps1 +262 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +366 -0
- package/ui/dist/assets/index-CaLPg0c-.css +1 -0
- package/ui/dist/assets/index-CgPruWYi.js +9 -0
- package/ui/dist/index.html +14 -0
- package/ui/dist/third-party-manifest.json +597 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { fork } from 'node:child_process';
|
|
2
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { createServer } from 'node:net';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { dirname, resolve, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
import { servicePaths, assertDurableRoot } from '../bin/service-control.mjs';
|
|
10
|
+
|
|
11
|
+
const { values } = parseArgs({
|
|
12
|
+
options: { port: { type: 'string', default: '8791' }, config: { type: 'string' } },
|
|
13
|
+
});
|
|
14
|
+
const port = Number(values.port);
|
|
15
|
+
const root = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), '..'));
|
|
16
|
+
const config = resolve(values.config || join(homedir(), '.mcp-pacemaker', 'servers.json'));
|
|
17
|
+
const paths = servicePaths(config, port);
|
|
18
|
+
assertDurableRoot(root);
|
|
19
|
+
if (Number(process.versions.node.split('.')[0]) < 20) throw new Error('Node.js >=20 is required.');
|
|
20
|
+
|
|
21
|
+
if (existsSync(paths.hold)) {
|
|
22
|
+
console.log(`Service :${port} is held stopped. Use the matching CLI start --port ${port} to resume deliberately.`);
|
|
23
|
+
} else {
|
|
24
|
+
await supervise();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function supervise() {
|
|
28
|
+
const identity = {
|
|
29
|
+
protocol: 1, root, config, port, socket: paths.socket, pid: process.pid, execPath: process.execPath,
|
|
30
|
+
id: randomUUID(), token: randomBytes(32).toString('hex'),
|
|
31
|
+
version: JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version,
|
|
32
|
+
};
|
|
33
|
+
let child;
|
|
34
|
+
let childExit;
|
|
35
|
+
let retry;
|
|
36
|
+
let stopping;
|
|
37
|
+
let stopRequested = false;
|
|
38
|
+
let holdRequested = false;
|
|
39
|
+
const clients = new Set();
|
|
40
|
+
const server = createServer((socket) => {
|
|
41
|
+
clients.add(socket);
|
|
42
|
+
socket.on('close', () => clients.delete(socket));
|
|
43
|
+
socket.on('error', () => socket.destroy());
|
|
44
|
+
socket.setTimeout(15000, () => socket.destroy());
|
|
45
|
+
let data = '';
|
|
46
|
+
let handled = false;
|
|
47
|
+
socket.on('data', (chunk) => {
|
|
48
|
+
if (handled) return;
|
|
49
|
+
data += chunk;
|
|
50
|
+
if (data.length > 4096) { socket.destroy(); return; }
|
|
51
|
+
if (!data.includes('\n')) return;
|
|
52
|
+
handled = true;
|
|
53
|
+
let request;
|
|
54
|
+
try { request = JSON.parse(data.slice(0, data.indexOf('\n'))); }
|
|
55
|
+
catch { socket.destroy(); return; }
|
|
56
|
+
if (request.id !== identity.id ||
|
|
57
|
+
request.token !== identity.token ||
|
|
58
|
+
request.action !== 'stop') {
|
|
59
|
+
socket.destroy();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
stop().then(() => {
|
|
63
|
+
socket.end(JSON.stringify({ id: identity.id, stopped: true, autostartHeld: true }) + '\n');
|
|
64
|
+
}, (error) => {
|
|
65
|
+
console.error(error.message);
|
|
66
|
+
socket.end(JSON.stringify({ id: identity.id, stopped: false }) + '\n');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
mkdirSync(dirname(config), { recursive: true, mode: 0o700 });
|
|
72
|
+
await new Promise((resolveListen, reject) => {
|
|
73
|
+
server.once('error', reject);
|
|
74
|
+
server.listen(paths.socket, resolveListen);
|
|
75
|
+
});
|
|
76
|
+
server.on('error', (error) => { console.error(error.message); process.exitCode = 1; });
|
|
77
|
+
try {
|
|
78
|
+
writeFileSync(paths.record, JSON.stringify(identity) + '\n', { mode: 0o600 });
|
|
79
|
+
if (existsSync(paths.hold)) {
|
|
80
|
+
server.close();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
launch();
|
|
84
|
+
} catch (error) {
|
|
85
|
+
server.close();
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
process.on('SIGINT', onSignal);
|
|
89
|
+
process.on('SIGTERM', onSignal);
|
|
90
|
+
|
|
91
|
+
function launch() {
|
|
92
|
+
if (stopRequested ||
|
|
93
|
+
existsSync(paths.hold)) {
|
|
94
|
+
server.close();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
child = fork(join(root, 'supervisor', 'bridge-child.mjs'), ['--port', String(port), '--config', config], {
|
|
98
|
+
execPath: process.execPath, execArgv: [], stdio: ['ignore', 'inherit', 'inherit', 'ipc'], windowsHide: true,
|
|
99
|
+
});
|
|
100
|
+
childExit = new Promise((resolveExit, reject) => {
|
|
101
|
+
child.once('error', reject);
|
|
102
|
+
child.once('exit', (code) => {
|
|
103
|
+
child = undefined;
|
|
104
|
+
resolveExit();
|
|
105
|
+
if (stopRequested) return;
|
|
106
|
+
if (code === 3) server.close();
|
|
107
|
+
else retry = setTimeout(launch, 2000);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
childExit.catch((error) => {
|
|
111
|
+
console.error(`Bridge launch failed: ${error.message}`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
server.close();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function stop(hold = true) {
|
|
118
|
+
// Persist before touching the child so logon/unlock and OS retries cannot restart it.
|
|
119
|
+
if (hold &&
|
|
120
|
+
!holdRequested) {
|
|
121
|
+
writeFileSync(paths.hold, JSON.stringify({ id: identity.id, root, config, port }) + '\n', { mode: 0o600 });
|
|
122
|
+
holdRequested = true;
|
|
123
|
+
}
|
|
124
|
+
if (stopping) return stopping;
|
|
125
|
+
stopRequested = true;
|
|
126
|
+
clearTimeout(retry);
|
|
127
|
+
stopping = (async () => {
|
|
128
|
+
if (child) {
|
|
129
|
+
if (child.connected) {
|
|
130
|
+
try {
|
|
131
|
+
await new Promise((resolveSend, reject) => child.send('stop', (error) => error ? reject(error) : resolveSend()));
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (error.code !== 'ERR_IPC_CHANNEL_CLOSED' &&
|
|
134
|
+
error.code !== 'EPIPE') throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
let timeout;
|
|
138
|
+
try {
|
|
139
|
+
await Promise.race([
|
|
140
|
+
childExit,
|
|
141
|
+
new Promise((_, reject) => {
|
|
142
|
+
timeout = setTimeout(() => reject(new Error('Bridge did not exit; do not replace its root. No force-kill attempted.')), 10000);
|
|
143
|
+
}),
|
|
144
|
+
]);
|
|
145
|
+
} finally {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (holdRequested) {
|
|
150
|
+
writeFileSync(paths.hold, JSON.stringify({ id: identity.id, root, config, port, stopped: true }) + '\n', { mode: 0o600 });
|
|
151
|
+
}
|
|
152
|
+
server.close();
|
|
153
|
+
process.removeListener('SIGINT', onSignal);
|
|
154
|
+
process.removeListener('SIGTERM', onSignal);
|
|
155
|
+
})().catch((error) => {
|
|
156
|
+
// Keep restart suppressed, but let a later explicit stop verify the real child exit.
|
|
157
|
+
// A timeout must not write a completed receipt or permanently poison reconciliation.
|
|
158
|
+
stopping = undefined;
|
|
159
|
+
throw error;
|
|
160
|
+
});
|
|
161
|
+
return stopping;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function onSignal() {
|
|
165
|
+
stop(false).then(() => {
|
|
166
|
+
for (const socket of clients) socket.destroy();
|
|
167
|
+
}, (error) => {
|
|
168
|
+
console.error(error.message);
|
|
169
|
+
process.exitCode = 1;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS Supervisor for the mcp-pacemaker bridge (Windows). Keeps it alive across crashes.
|
|
3
|
+
.DESCRIPTION
|
|
4
|
+
Runs the managed supervisor. A targeted CLI stop holds automatic restarts until an
|
|
5
|
+
explicit CLI start. Node.js 20 or newer must be on PATH.
|
|
6
|
+
.PARAMETER Port Port the bridge listens on (default 8791). Must match your client config.
|
|
7
|
+
.OUTPUTS
|
|
8
|
+
Supervisor diagnostics.
|
|
9
|
+
.EXAMPLE
|
|
10
|
+
.\supervise.ps1 -Port 8791
|
|
11
|
+
#>
|
|
12
|
+
[CmdletBinding()]
|
|
13
|
+
param([ValidateRange(1, 65535)][int]$Port = 8791)
|
|
14
|
+
|
|
15
|
+
$ErrorActionPreference = 'Stop'
|
|
16
|
+
$supervisor = Join-Path -Path $PSScriptRoot -ChildPath 'supervise.mjs'
|
|
17
|
+
|
|
18
|
+
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
|
19
|
+
throw "node is not on PATH. Install Node.js (>=20) and retry."
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
& node $supervisor --port $Port
|
|
23
|
+
exit $LASTEXITCODE
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Starts the managed supervisor. Arg 1 = port.
|
|
3
|
+
PORT="${1:-8791}"
|
|
4
|
+
DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
5
|
+
|
|
6
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
7
|
+
echo "node is not on PATH. Install Node.js (>=20) and retry." >&2
|
|
8
|
+
exit 1
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
exec node "$DIR/supervisor/supervise.mjs" --port "$PORT"
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Windows security helper
|
|
2
|
+
|
|
3
|
+
`bin/windows/PoolingSecurityHelper.exe` is an own-source, AnyCPU .NET Framework
|
|
4
|
+
console program. It contains both the native security reader and the operation
|
|
5
|
+
code; there is no companion reader DLL or third-party runtime dependency.
|
|
6
|
+
The `bin/windows/src/` source and build metadata ship alongside the executable.
|
|
7
|
+
|
|
8
|
+
## Runtime
|
|
9
|
+
|
|
10
|
+
Requires Windows with .NET Framework 4.6.2 or later. Windows 10 1607+, Windows 11,
|
|
11
|
+
and Windows Server 2016+ include that version or newer. Windows PowerShell 5.1
|
|
12
|
+
alone on older Windows does not establish this prerequisite.
|
|
13
|
+
PowerShell is **not** launched by the helper.
|
|
14
|
+
There is no runtime compilation, download, elevation, or policy change.
|
|
15
|
+
AnyCPU uses the installed Framework's process architecture; native calls use
|
|
16
|
+
pointer-sized handles. Windows ARM64 execution has not been validated.
|
|
17
|
+
|
|
18
|
+
The Node caller must launch the package-relative executable directly (no shell)
|
|
19
|
+
with exactly one action argument. Paths are passed in the environment, never in
|
|
20
|
+
arguments. No action enables privileges.
|
|
21
|
+
|
|
22
|
+
### Batch file operations
|
|
23
|
+
|
|
24
|
+
| Action | Environment | UTF-8 stdin |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| `inspect-access` | `MCP_POOL_SOURCE` | Unused |
|
|
27
|
+
| `stage` | `MCP_POOL_SOURCE`, `MCP_POOL_TEMP` (new destination) | `{"expected":{"identity":"…","revision":"…","security":"…"},"bytes":"<base64>"}` |
|
|
28
|
+
| `move-no-replace` | `MCP_POOL_SOURCE`, `MCP_POOL_TEMP` (destination) | `{"expected":{"identity":"…","revision":"…","security":"…"}}` |
|
|
29
|
+
|
|
30
|
+
Each successful action writes one JSON line with exactly
|
|
31
|
+
`{identity, revision, security, size}`. Pass only the first three fields back in
|
|
32
|
+
`expected`, not the complete result. `identity` is lowercase hexadecimal
|
|
33
|
+
`volumeSerial:fileIndexHighAndLow` (8 digits, colon, 16 digits). `revision` is
|
|
34
|
+
lowercase SHA256 of the complete bytes; `size` is their integer byte count.
|
|
35
|
+
`security` is a lowercase SHA256 comparison value, not a descriptor to log.
|
|
36
|
+
Custom config paths, including spaces and Unicode, use the same environment API.
|
|
37
|
+
|
|
38
|
+
All three actions require a disk file, no final-component reparse point, exactly
|
|
39
|
+
one hard link, and at most 1 MiB. Only Normal/Archive file attributes are supported;
|
|
40
|
+
read-only, compressed, encrypted, sparse, hidden, and other attributes fail closed.
|
|
41
|
+
The input envelope is limited to 1,400,000 bytes, strict UTF-8 and depth 8.
|
|
42
|
+
Edited bytes must be canonical base64 decoding to at most 1 MiB of UTF-8.
|
|
43
|
+
The helper does not parse the edited configuration; complete JSON validation and
|
|
44
|
+
allowed field edits remain the Node coordinator's responsibility.
|
|
45
|
+
|
|
46
|
+
Native `GetSecurityInfo` queries the held handle with `READ_CONTROL` sections
|
|
47
|
+
`0x1f7`. The new actions never call the legacy privileged audit reader.
|
|
48
|
+
The security hash frames `mcp-access-v1`, native owner SID, group SID,
|
|
49
|
+
DACL-protection bit, raw DACL, native label ACL, and attributes with `BinaryWriter`.
|
|
50
|
+
Byte arrays have signed Int32 lengths (`-1` for null). The discriminator is a
|
|
51
|
+
BinaryWriter UTF-8 string; protection and attributes are UInt32, all little-endian.
|
|
52
|
+
The DACL's `SE_DACL_AUTO_INHERITED` (`0x400`) bookkeeping is not hashed: Windows can
|
|
53
|
+
clear it while retaining the exact grants. Defaulted/auto-inherit-request and
|
|
54
|
+
other unsupported descriptor control state is refused. Audit control flags are
|
|
55
|
+
outside this non-audit fingerprint; label ACE bytes and flags remain exact.
|
|
56
|
+
|
|
57
|
+
Supported DACLs have non-null raw ACLs containing ordinary non-callback allow/deny
|
|
58
|
+
ACEs. Unsupported object/callback/policy ACEs fail closed. No label, or one
|
|
59
|
+
low/medium label with a nonzero mask composed only of NW/NR/NX (`1` through `7`),
|
|
60
|
+
is supported. Only the inherited label ACE flag is allowed. Other native non-audit
|
|
61
|
+
policy is refused.
|
|
62
|
+
|
|
63
|
+
`stage` opens the source with actual `FILE_READ_DATA | FILE_WRITE_DATA |
|
|
64
|
+
READ_CONTROL` access. Parent DeleteChild authority does not bypass source write
|
|
65
|
+
denial. The held source handle denies other data writers/deleters during staging.
|
|
66
|
+
Expected identity, bytes, and security are checked before creation and again
|
|
67
|
+
around writing. When the source owner equals the caller's user SID or current
|
|
68
|
+
token default owner, that owner is retained. Retaining an already assignable
|
|
69
|
+
default owner avoids an unnecessary ownership change across a version roundtrip.
|
|
70
|
+
Otherwise the caller must already obtain source `READ_CONTROL | WRITE_DAC` through
|
|
71
|
+
`ReOpenFile` on the same object; only simple allow DACLs without OWNER RIGHTS or
|
|
72
|
+
CREATOR special SIDs qualify. The candidate owner then becomes the creator.
|
|
73
|
+
The source group must remain exact or creation/verification fails.
|
|
74
|
+
|
|
75
|
+
The candidate uses native `CREATE_NEW` with `SECURITY_ATTRIBUTES` containing the
|
|
76
|
+
accepted owner/group/DACL/protection and supported labels **before any bytes**.
|
|
77
|
+
It never clones audit entries or sets SACL protection. A label-bearing creation
|
|
78
|
+
descriptor marks the SACL defaulted (`SE_SACL_DEFAULTED`); this lets Windows inherit
|
|
79
|
+
folder auditing while retaining the supplied mandatory label. The label remains
|
|
80
|
+
part of the exact empty-file verification. Windows inherits the
|
|
81
|
+
destination folder's auditing, including when the source protects its audit ACL.
|
|
82
|
+
File-specific auditing is not detected or promised to carry forward. The UI must
|
|
83
|
+
provide the agreed nonblocking audit-inheritance warning without claiming
|
|
84
|
+
custom rules were detected.
|
|
85
|
+
|
|
86
|
+
The empty candidate's security must equal the accepted fingerprint before
|
|
87
|
+
`WriteFile`. Bytes are written through that owned handle, flushed using
|
|
88
|
+
`FlushFileBuffers`, and then hashed and security-checked again. Security mismatch
|
|
89
|
+
before writing can leave an empty, securely created candidate. A later failure
|
|
90
|
+
can leave a partially/fully written candidate; callers must validate ownership
|
|
91
|
+
before cleanup, never blindly delete a destination on any failure.
|
|
92
|
+
|
|
93
|
+
`move-no-replace` holds a source handle with share-read/share-delete, denies data
|
|
94
|
+
writers, and checks identity/content/security before `MoveFileExW(..., 0)`.
|
|
95
|
+
It checks the destination and held object afterward and returns the moved
|
|
96
|
+
descriptor. Existing destinations are never overwritten. There is no exists-check
|
|
97
|
+
followed by an overwriting rename.
|
|
98
|
+
|
|
99
|
+
**Limits:** the rename API is path-based. Another actor with delete/rename or
|
|
100
|
+
directory/security authority can still swap the source path/ancestors between
|
|
101
|
+
validation and the move, or change security during an operation. Postchecks
|
|
102
|
+
detect some races but cannot undo them. This is not CAS, an atomic two-file
|
|
103
|
+
transaction, a power-loss guarantee, or a complete metadata clone. A failed move
|
|
104
|
+
postcheck can mean the move already happened; the coordinator must inspect
|
|
105
|
+
transaction state before recovery. Deadline/commit gating, rollback/recovery,
|
|
106
|
+
watcher coordination, and the missing-active interval belong to Node.
|
|
107
|
+
|
|
108
|
+
| Exit | New-action meaning |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| `0` | One descriptor JSON line; empty stderr |
|
|
111
|
+
| `3` | `PoolingConflictException` for an expected/state mismatch, or `Win32Exception` with native `80`/`183` for an existing destination |
|
|
112
|
+
| `1` | Policy, request, I/O, access, or other failure |
|
|
113
|
+
|
|
114
|
+
Failures have empty stdout. Stderr is exactly `MCPERR type=<exception type>` and,
|
|
115
|
+
for a `Win32Exception`, a second line `MCPERR nativeError=<integer>`. Relevant
|
|
116
|
+
types are `PoolingPolicyException` (unsupported or mismatched creation security),
|
|
117
|
+
`PoolingConflictException`, `Win32Exception`, `ArgumentException`,
|
|
118
|
+
`FormatException`, `DecoderFallbackException`, and `IOException`. Native `5` is
|
|
119
|
+
access denied; `32` is a sharing violation; `2`/`3` are missing path components.
|
|
120
|
+
No exception messages, paths, token information, config bytes, or descriptors are
|
|
121
|
+
printed. Legacy stale-security conflicts retain their empty-stderr behavior.
|
|
122
|
+
|
|
123
|
+
### Legacy compatibility
|
|
124
|
+
|
|
125
|
+
`inspect` and `copy` remain available with their existing contract:
|
|
126
|
+
|
|
127
|
+
| Environment variable | Used by | Value |
|
|
128
|
+
| --- | --- | --- |
|
|
129
|
+
| `MCP_POOL_SOURCE` | Both | Source config path |
|
|
130
|
+
| `MCP_POOL_SECURITY` | Copy | Previously inspected fingerprint |
|
|
131
|
+
| `MCP_POOL_TEMP` | Copy | Existing empty replacement file |
|
|
132
|
+
| `MCP_POOL_BACKUP` | Copy | Existing empty backup file |
|
|
133
|
+
|
|
134
|
+
`inspect` writes one line: `F:<base64 SHA256>` for complete security or
|
|
135
|
+
`P:<base64 SHA256>` when audit read is denied. These are comparison values,
|
|
136
|
+
not log fields. The caller must refuse automatic writes for partial (`P:`)
|
|
137
|
+
state. Successful `copy` writes nothing to stdout.
|
|
138
|
+
|
|
139
|
+
| Exit code | Meaning |
|
|
140
|
+
| --- | --- |
|
|
141
|
+
| 0 | Inspection or copy/verification succeeded |
|
|
142
|
+
| 3 | Source fingerprint changed before or after copy; caller reports conflict |
|
|
143
|
+
| 1 | Invalid input, I/O failure, or security cannot be preserved |
|
|
144
|
+
|
|
145
|
+
On failure, stderr contains only `MCPERR type=<exception type>` and, for native
|
|
146
|
+
errors, `MCPERR nativeError=<integer>`. No path, descriptor, token, config bytes,
|
|
147
|
+
or exception message is logged. Caller owns process timeout and error mapping.
|
|
148
|
+
|
|
149
|
+
Fingerprint framing is unchanged: canonical native descriptor (mask `0x1f7`),
|
|
150
|
+
separate managed audit descriptor, and integer file attributes, written with
|
|
151
|
+
`BinaryWriter` length prefixes before SHA256. Copy preserves owner/group/DACL
|
|
152
|
+
through SDDL and verifies the entire fingerprint on both staging files, then
|
|
153
|
+
rechecks the source. It does not copy unsupported audit/integrity/attribute
|
|
154
|
+
state or weaken it to force success. It never writes config contents.
|
|
155
|
+
|
|
156
|
+
## Build and verify
|
|
157
|
+
|
|
158
|
+
Prerequisites, installed by the developer or CI image:
|
|
159
|
+
|
|
160
|
+
1. Visual Studio or Build Tools with MSBuild and its Roslyn `csc.exe`, supporting
|
|
161
|
+
`/deterministic` and `/pathmap`. `vswhere` discovers current releases, including
|
|
162
|
+
Visual Studio 2026; an explicit compiler path is also supported.
|
|
163
|
+
2. The .NET Framework **4.6.2 targeting pack** (reference assemblies, not a new
|
|
164
|
+
runtime). The script reports missing prerequisites; it installs nothing.
|
|
165
|
+
3. Windows PowerShell 5.1 or PowerShell 7 for the build script only.
|
|
166
|
+
|
|
167
|
+
From the repository root:
|
|
168
|
+
|
|
169
|
+
```powershell
|
|
170
|
+
# Update executable and metadata after source changes.
|
|
171
|
+
.\tools\windows-security-helper\build.ps1
|
|
172
|
+
|
|
173
|
+
# Read-only source/toolchain/hash check plus byte-for-byte rebuild.
|
|
174
|
+
.\tools\windows-security-helper\build.ps1 -Verify
|
|
175
|
+
|
|
176
|
+
# Select an already-installed compiler explicitly when reproducing recorded bytes.
|
|
177
|
+
.\tools\windows-security-helper\build.ps1 -Verify -CompilerPath 'C:\path\to\Roslyn\csc.exe'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The build uses `/noconfig /nostdlib+` with explicit `mscorlib.dll`, `System.dll`,
|
|
181
|
+
`System.Core.dll`, and `System.Web.Extensions.dll` references. JSON serialization
|
|
182
|
+
uses .NET Framework's built-in `JavaScriptSerializer`, not an npm dependency.
|
|
183
|
+
Sources are `AssemblyInfo.cs`, `PoolingSecurityReader.cs`,
|
|
184
|
+
`PoolingSecurityHelper.cs`, and `PoolingNativeFiles.cs`, with fixed options,
|
|
185
|
+
LF-normalized UTF-8 source, and mapped source paths.
|
|
186
|
+
It compiles twice under different temporary paths and refuses unequal bytes.
|
|
187
|
+
Temporary build files are removed by explicit path. No `dotnet build`, runtime
|
|
188
|
+
Framework compiler, NuGet restore, or external binary is involved.
|
|
189
|
+
|
|
190
|
+
`PoolingSecurityHelper.build.json` records compiler version/hash, reference
|
|
191
|
+
hashes, options, normalized source/build-script hashes, and executable SHA256.
|
|
192
|
+
`-Verify` checks all inputs and requires rebuilt bytes to match the packaged
|
|
193
|
+
binary. Verification never updates the binary or metadata.
|
|
194
|
+
|
|
195
|
+
Reproducibility is scoped to the **recorded compiler and references**. A newer
|
|
196
|
+
CI image/compiler may produce different bytes; do not treat a changed toolchain
|
|
197
|
+
as verification of the old binary. Use the recorded installed toolchain or
|
|
198
|
+
explicitly rebuild, review, and validate the updated assets. This is integrity
|
|
199
|
+
checking, not code signing or protection against someone replacing all assets.
|
|
200
|
+
|
|
201
|
+
The published executable must contain only the production sources above.
|
|
202
|
+
Audit-denial/delay test variants belong in disposable directories outside `bin/`
|
|
203
|
+
and must never be included in an npm tarball. Release validation must also run
|
|
204
|
+
the real package on supported Windows/Node versions and verify packaged file
|
|
205
|
+
inclusion. This build alone does not establish those platform results.
|
|
206
|
+
|
|
207
|
+
## Targeted tests
|
|
208
|
+
|
|
209
|
+
```powershell
|
|
210
|
+
npm exec --no --offline --package=node@20.20.2 -- node --test test/windows-security-helper.test.mjs test/windows-batch-security.test.mjs
|
|
211
|
+
npm exec --no --offline --package=node@22.23.2 -- node --test test/windows-security-helper.test.mjs test/windows-batch-security.test.mjs
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The real-helper tests cover descriptor stability, full-byte staging, exact 1 MiB
|
|
215
|
+
bounds, stale identity/revision/security, malformed/bounded input, held writers,
|
|
216
|
+
hard links, and both collision points. There are no successful-path mocks.
|
|
217
|
+
|
|
218
|
+
The isolated `TEST-SETUP` audit fixture requires an audit-reading oracle token
|
|
219
|
+
and an existing Explorer window. Otherwise that one test explicitly skips.
|
|
220
|
+
Explorer discovery first checks for a process in the current Windows session,
|
|
221
|
+
before COM access or fixture policy changes. An absent process/window produces
|
|
222
|
+
a structured capability reason; discovery exceptions and later worker failures
|
|
223
|
+
remain failures. A skip does not establish audit-oracle coverage: that requires
|
|
224
|
+
a non-skipped result on a machine with both prerequisites.
|
|
225
|
+
It launches the **same Node executable** through Explorer, verifies medium
|
|
226
|
+
integrity and absence of SeSecurityPrivilege, then runs the packaged helper
|
|
227
|
+
directly. The elevated oracle separately verifies raw DACL/group/labels,
|
|
228
|
+
owner rules, unchanged failed sources, and the retained original's complete
|
|
229
|
+
audit descriptor. The mixed audit case retains one explicit plus one inherited
|
|
230
|
+
source rule on `previous`; its new candidate has only the parent's inherited
|
|
231
|
+
rule. A protected audit source also permits folder inheritance on the candidate.
|
|
232
|
+
An incompatible inherited label must leave the candidate empty.
|
|
233
|
+
|
|
234
|
+
`test/fixtures/windows-batch-security/FixturePolicy.cs` is compiled only by
|
|
235
|
+
TEST-SETUP to establish synthetic raw DACLs and labels; it is not linked into,
|
|
236
|
+
loaded by, or shipped as a dependency of the production helper. All fixture
|
|
237
|
+
policy changes are confined to a fresh owned temporary directory, removed by
|
|
238
|
+
the test. No live files, tasks, bridge, or token privileges are changed.
|