@modelprofile.com/browser-runtime 5.7.2 → 6.0.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 +45 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.flexprovider.js +2 -2
- package/dist_ts/classes.framed.d.ts +3 -1
- package/dist_ts/classes.framed.js +5 -1
- package/dist_ts/classes.runtime.d.ts +13 -0
- package/dist_ts/classes.runtime.js +98 -30
- package/dist_ts/classes.runtimeownership.d.ts +11 -2
- package/dist_ts/classes.runtimeownership.js +126 -48
- package/dist_ts/interfaces.d.ts +5 -1
- package/dist_ts/plugins.d.ts +2 -2
- package/dist_ts/plugins.js +3 -3
- package/package.json +3 -5
- package/readme.md +12 -6
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.flexprovider.ts +2 -2
- package/ts/classes.framed.ts +6 -1
- package/ts/classes.runtime.ts +115 -30
- package/ts/classes.runtimeownership.ts +138 -47
- package/ts/interfaces.ts +5 -1
- package/ts/plugins.ts +2 -2
- package/.smartconfig.json +0 -34
|
@@ -6,6 +6,8 @@ const metadataSchema = 1;
|
|
|
6
6
|
const maximumMetadataBytes = 4096;
|
|
7
7
|
const maximumProcFileBytes = 2 * 1024 * 1024;
|
|
8
8
|
const anchorInitializationAttempts = 200;
|
|
9
|
+
const emptyCommandAttempts = 5;
|
|
10
|
+
const emptyCommandRetryDelayMs = 50;
|
|
9
11
|
const generationIdPattern = /^[A-Za-z0-9_-]{32}$/u;
|
|
10
12
|
const hashPattern = /^[a-f0-9]{64}$/u;
|
|
11
13
|
const bootIdPattern = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/u;
|
|
@@ -59,7 +61,26 @@ export interface IBrowserRuntimeGeneration {
|
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
type TInspectionResult = 'clear' | 'held' | 'indeterminate';
|
|
62
|
-
|
|
64
|
+
/**
|
|
65
|
+
* `same` is every one of the four `/proc/<pid>/status` `Uid:` identifiers, `other` is none of
|
|
66
|
+
* them, and `shared` is a setuid credential transition that names this uid in some but not all
|
|
67
|
+
* of them (`sudo`, `su`, `passwd`, a daemon that assumed this identity). A `shared` process can
|
|
68
|
+
* still open this uid's profile, so it is a scan candidate; it is never a whole-scan fence.
|
|
69
|
+
*/
|
|
70
|
+
type TProcessUidState = 'same' | 'other' | 'shared' | 'missing' | 'indeterminate';
|
|
71
|
+
type TEmptyCommandResolution =
|
|
72
|
+
| { kind: 'clear' }
|
|
73
|
+
| { kind: 'indeterminate' }
|
|
74
|
+
| { kind: 'command'; cmdline: plugins.Buffer };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Waits for one bounded retry step. The timer is referenced for its whole lifetime and always
|
|
78
|
+
* fires, so it is released as soon as the step ends and never outlives the acquisition it
|
|
79
|
+
* belongs to. An unreferenced timer would let the process exit in the middle of an inspection.
|
|
80
|
+
*/
|
|
81
|
+
const delayStep = (millisecondsArg: number): Promise<void> => new Promise<void>((resolve) => {
|
|
82
|
+
setTimeout(resolve, millisecondsArg);
|
|
83
|
+
});
|
|
63
84
|
|
|
64
85
|
const isMissingError = (errorArg: unknown): boolean => (
|
|
65
86
|
(errorArg as NodeJS.ErrnoException).code === 'ENOENT'
|
|
@@ -1085,9 +1106,13 @@ export class BrowserRuntimeOwnership {
|
|
|
1085
1106
|
}
|
|
1086
1107
|
|
|
1087
1108
|
private async inspectProfileProcesses(profileRootsArg: string[]): Promise<TInspectionResult> {
|
|
1088
|
-
const roots =
|
|
1109
|
+
const roots = await this.presentProfileRoots(profileRootsArg);
|
|
1110
|
+
// The inspection exists only to protect a profile root that is about to be removed. When no
|
|
1111
|
+
// root is present there is nothing to protect and no containment match is possible, so the
|
|
1112
|
+
// process table is never enumerated.
|
|
1113
|
+
if (roots.length === 0) return 'clear';
|
|
1089
1114
|
let indeterminate = false;
|
|
1090
|
-
for (const pid of await this.
|
|
1115
|
+
for (const pid of await this.listCandidateProcessIds()) {
|
|
1091
1116
|
let cmdline: plugins.Buffer;
|
|
1092
1117
|
try {
|
|
1093
1118
|
cmdline = await this.readBoundedBuffer(
|
|
@@ -1095,6 +1120,8 @@ export class BrowserRuntimeOwnership {
|
|
|
1095
1120
|
maximumProcFileBytes,
|
|
1096
1121
|
);
|
|
1097
1122
|
} catch {
|
|
1123
|
+
// A candidate whose command line cannot be read stays fail-closed: its identity is known
|
|
1124
|
+
// to involve this uid and nothing proves it is not using one of these profile roots.
|
|
1098
1125
|
const uidState = await this.processStillExists(pid);
|
|
1099
1126
|
if (uidState === 'missing' || uidState === 'other') continue;
|
|
1100
1127
|
indeterminate = true;
|
|
@@ -1102,37 +1129,66 @@ export class BrowserRuntimeOwnership {
|
|
|
1102
1129
|
}
|
|
1103
1130
|
if (cmdline.byteLength === 0) {
|
|
1104
1131
|
await this.afterEmptyProcessCmdline?.(pid);
|
|
1105
|
-
|
|
1106
|
-
continue;
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
cmdlineText = new TextDecoder('utf-8', { fatal: true }).decode(cmdline);
|
|
1111
|
-
} catch {
|
|
1112
|
-
indeterminate = true;
|
|
1113
|
-
continue;
|
|
1114
|
-
}
|
|
1115
|
-
const argumentsList = cmdlineText.split('\0');
|
|
1116
|
-
if (argumentsList.at(-1) === '') argumentsList.pop();
|
|
1117
|
-
for (let index = 0; index < argumentsList.length; index += 1) {
|
|
1118
|
-
const argument = argumentsList[index]!;
|
|
1119
|
-
let profilePath: string | undefined;
|
|
1120
|
-
if (argument === '--user-data-dir') {
|
|
1121
|
-
profilePath = argumentsList[index + 1];
|
|
1122
|
-
} else if (argument.startsWith('--user-data-dir=')) {
|
|
1123
|
-
profilePath = argument.slice('--user-data-dir='.length);
|
|
1124
|
-
}
|
|
1125
|
-
if (!profilePath || !plugins.path.isAbsolute(profilePath)) continue;
|
|
1126
|
-
const resolved = plugins.path.resolve(profilePath);
|
|
1127
|
-
if (roots.some((root) => resolved === root || this.pathIsWithin(root, resolved))) {
|
|
1128
|
-
return 'held';
|
|
1132
|
+
const resolution = await this.resolveEmptyCommandProcess(pid);
|
|
1133
|
+
if (resolution.kind === 'clear') continue;
|
|
1134
|
+
if (resolution.kind === 'indeterminate') {
|
|
1135
|
+
indeterminate = true;
|
|
1136
|
+
continue;
|
|
1129
1137
|
}
|
|
1138
|
+
cmdline = resolution.cmdline;
|
|
1130
1139
|
}
|
|
1140
|
+
const containment = this.commandLineContainment(cmdline, roots);
|
|
1141
|
+
if (containment === 'held') return 'held';
|
|
1142
|
+
if (containment === 'indeterminate') indeterminate = true;
|
|
1131
1143
|
}
|
|
1132
1144
|
return indeterminate ? 'indeterminate' : 'clear';
|
|
1133
1145
|
}
|
|
1134
1146
|
|
|
1135
|
-
private
|
|
1147
|
+
private commandLineContainment(
|
|
1148
|
+
cmdlineArg: plugins.Buffer,
|
|
1149
|
+
rootsArg: string[],
|
|
1150
|
+
): TInspectionResult {
|
|
1151
|
+
let cmdlineText: string;
|
|
1152
|
+
try {
|
|
1153
|
+
cmdlineText = new TextDecoder('utf-8', { fatal: true }).decode(cmdlineArg);
|
|
1154
|
+
} catch {
|
|
1155
|
+
return 'indeterminate';
|
|
1156
|
+
}
|
|
1157
|
+
const argumentsList = cmdlineText.split('\0');
|
|
1158
|
+
if (argumentsList.at(-1) === '') argumentsList.pop();
|
|
1159
|
+
for (let index = 0; index < argumentsList.length; index += 1) {
|
|
1160
|
+
const argument = argumentsList[index]!;
|
|
1161
|
+
let profilePath: string | undefined;
|
|
1162
|
+
if (argument === '--user-data-dir') {
|
|
1163
|
+
profilePath = argumentsList[index + 1];
|
|
1164
|
+
} else if (argument.startsWith('--user-data-dir=')) {
|
|
1165
|
+
profilePath = argument.slice('--user-data-dir='.length);
|
|
1166
|
+
}
|
|
1167
|
+
if (!profilePath || !plugins.path.isAbsolute(profilePath)) continue;
|
|
1168
|
+
const resolved = plugins.path.resolve(profilePath);
|
|
1169
|
+
if (rootsArg.some((root) => resolved === root || this.pathIsWithin(root, resolved))) {
|
|
1170
|
+
return 'held';
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
return 'clear';
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
private async presentProfileRoots(profileRootsArg: string[]): Promise<string[]> {
|
|
1177
|
+
const roots: string[] = [];
|
|
1178
|
+
for (const profileRoot of profileRootsArg) {
|
|
1179
|
+
const root = plugins.path.resolve(profileRoot);
|
|
1180
|
+
try {
|
|
1181
|
+
await plugins.fsPromises.lstat(root, { bigint: true });
|
|
1182
|
+
} catch (error) {
|
|
1183
|
+
if (isMissingError(error)) continue;
|
|
1184
|
+
throw new BrowserRuntimeError('FENCED', 'runtime profile root is unreadable');
|
|
1185
|
+
}
|
|
1186
|
+
roots.push(root);
|
|
1187
|
+
}
|
|
1188
|
+
return roots;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private async listCandidateProcessIds(): Promise<number[]> {
|
|
1136
1192
|
let entries: string[];
|
|
1137
1193
|
try {
|
|
1138
1194
|
// /proc can report DT_UNKNOWN. Node's eager Dirent conversion then
|
|
@@ -1161,8 +1217,11 @@ export class BrowserRuntimeOwnership {
|
|
|
1161
1217
|
indeterminate = true;
|
|
1162
1218
|
continue;
|
|
1163
1219
|
}
|
|
1220
|
+
// A credential state alone never fences the scan. An unrelated process is discarded here
|
|
1221
|
+
// only when it definitely belongs to another uid; every process this uid owns or shares is
|
|
1222
|
+
// a candidate whose command line decides it in inspectProfileProcesses().
|
|
1164
1223
|
const uidState = await this.processStillExists(pid);
|
|
1165
|
-
if (uidState === 'same') processIds.push(pid);
|
|
1224
|
+
if (uidState === 'same' || uidState === 'shared') processIds.push(pid);
|
|
1166
1225
|
else if (uidState === 'indeterminate') indeterminate = true;
|
|
1167
1226
|
}
|
|
1168
1227
|
if (indeterminate) {
|
|
@@ -1208,31 +1267,63 @@ export class BrowserRuntimeOwnership {
|
|
|
1208
1267
|
}
|
|
1209
1268
|
if (ids.every((id) => id === this.uid)) return 'same';
|
|
1210
1269
|
if (ids.every((id) => id !== this.uid)) return 'other';
|
|
1211
|
-
return '
|
|
1270
|
+
return 'shared';
|
|
1212
1271
|
}
|
|
1213
1272
|
|
|
1214
|
-
|
|
1273
|
+
/**
|
|
1274
|
+
* An empty `/proc/<pid>/cmdline` is not a decision. A candidate publishes one for a bounded
|
|
1275
|
+
* window while it is mid-`execve` or has already released its mm on the way to becoming a
|
|
1276
|
+
* zombie, and during that window `State:` still reports a running process. Re-read the
|
|
1277
|
+
* identity and the command line across that window before deciding anything.
|
|
1278
|
+
*/
|
|
1279
|
+
private async resolveEmptyCommandProcess(pidArg: number): Promise<TEmptyCommandResolution> {
|
|
1215
1280
|
const processPath = plugins.path.join(this.procRoot, String(pidArg));
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1281
|
+
for (let attempt = 0; attempt < emptyCommandAttempts; attempt += 1) {
|
|
1282
|
+
let status: string;
|
|
1283
|
+
try {
|
|
1284
|
+
status = await this.readBoundedFile(
|
|
1285
|
+
plugins.path.join(processPath, 'status'),
|
|
1286
|
+
64 * 1024,
|
|
1287
|
+
);
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
return await this.departedProcessResolution(processPath, error);
|
|
1290
|
+
}
|
|
1221
1291
|
const uidState = this.parseProcessUidState(status);
|
|
1222
|
-
if (uidState === 'other') return
|
|
1223
|
-
if (uidState !== 'same') return
|
|
1292
|
+
if (uidState === 'other') return { kind: 'clear' };
|
|
1293
|
+
if (uidState !== 'same' && uidState !== 'shared') return { kind: 'indeterminate' };
|
|
1224
1294
|
const stateLines = status.split('\n').filter((line) => line.startsWith('State:'));
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
if (
|
|
1228
|
-
|
|
1229
|
-
// alone is insufficient: the PID directory must also have disappeared.
|
|
1295
|
+
if (stateLines.length !== 1) return { kind: 'indeterminate' };
|
|
1296
|
+
// A zombie has released its mm, holds no descriptor and cannot be using any profile root.
|
|
1297
|
+
if (/^State:[\t ]+Z(?:[\t ]|$)/u.test(stateLines[0]!)) return { kind: 'clear' };
|
|
1298
|
+
let cmdline: plugins.Buffer;
|
|
1230
1299
|
try {
|
|
1231
|
-
await
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1300
|
+
cmdline = await this.readBoundedBuffer(
|
|
1301
|
+
plugins.path.join(processPath, 'cmdline'),
|
|
1302
|
+
maximumProcFileBytes,
|
|
1303
|
+
);
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
return await this.departedProcessResolution(processPath, error);
|
|
1235
1306
|
}
|
|
1307
|
+
if (cmdline.byteLength > 0) return { kind: 'command', cmdline };
|
|
1308
|
+
if (attempt + 1 < emptyCommandAttempts) await delayStep(emptyCommandRetryDelayMs);
|
|
1309
|
+
}
|
|
1310
|
+
// Still running and still command-less after the whole window. Nothing proves this process is
|
|
1311
|
+
// not using one of the inspected roots, so it stays fail-closed.
|
|
1312
|
+
return { kind: 'indeterminate' };
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
private async departedProcessResolution(
|
|
1316
|
+
processPathArg: string,
|
|
1317
|
+
errorArg: unknown,
|
|
1318
|
+
): Promise<TEmptyCommandResolution> {
|
|
1319
|
+
if (!isMissingError(errorArg)) return { kind: 'indeterminate' };
|
|
1320
|
+
// A missing status or command-line file alone is insufficient: the PID directory must also
|
|
1321
|
+
// have disappeared before the process counts as departed.
|
|
1322
|
+
try {
|
|
1323
|
+
await plugins.fsPromises.lstat(processPathArg, { bigint: true });
|
|
1324
|
+
return { kind: 'indeterminate' };
|
|
1325
|
+
} catch (statError) {
|
|
1326
|
+
return isMissingError(statError) ? { kind: 'clear' } : { kind: 'indeterminate' };
|
|
1236
1327
|
}
|
|
1237
1328
|
}
|
|
1238
1329
|
|
package/ts/interfaces.ts
CHANGED
|
@@ -31,7 +31,11 @@ export interface IBrowserResourceKey {
|
|
|
31
31
|
export interface IBrowserAttachmentBinding {
|
|
32
32
|
attachmentAuthorityId: string;
|
|
33
33
|
attachmentRevision: number;
|
|
34
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The set of sessions attached to the resource. Order is not significant and duplicates are
|
|
36
|
+
* rejected. An empty set is detached, and revision `0` requires an empty set.
|
|
37
|
+
*/
|
|
38
|
+
sessionIds: TQualifiedBrowserSessionId[];
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export interface ICreateBrowserResourceRequest {
|
package/ts/plugins.ts
CHANGED
|
@@ -16,12 +16,12 @@ export { Buffer, crypto, dns, fs, fsPromises, http, net, os, path, stream, tls,
|
|
|
16
16
|
|
|
17
17
|
// foss.global scopes
|
|
18
18
|
import * as flexharness from '@modelprofile.com/flexharness';
|
|
19
|
-
import * as
|
|
19
|
+
import * as flexharnessTools from '@modelprofile.com/flexharness/tools';
|
|
20
20
|
import * as smartipc from '@push.rocks/smartipc';
|
|
21
21
|
import * as smartmcp from '@push.rocks/smartmcp';
|
|
22
22
|
import * as smartpuppeteer from '@push.rocks/smartpuppeteer';
|
|
23
23
|
|
|
24
|
-
export { flexharness,
|
|
24
|
+
export { flexharness, flexharnessTools, smartipc, smartmcp, smartpuppeteer };
|
|
25
25
|
|
|
26
26
|
// third-party scope
|
|
27
27
|
import ipaddr from 'ipaddr.js';
|
package/.smartconfig.json
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"@git.zone/cli": {
|
|
3
|
-
"schemaVersion": 2,
|
|
4
|
-
"projectType": "npm",
|
|
5
|
-
"module": {
|
|
6
|
-
"githost": "code.foss.global",
|
|
7
|
-
"gitscope": "modelprofile.com",
|
|
8
|
-
"gitrepo": "browser-runtime",
|
|
9
|
-
"description": "Parent-owned, resource-centric Chromium runtime with revisioned attachment fencing, authenticated human and agent control, fail-closed egress, bounded artifacts, and Flex/MCP adapters.",
|
|
10
|
-
"npmPackagename": "@modelprofile.com/browser-runtime",
|
|
11
|
-
"license": "MIT",
|
|
12
|
-
"projectDomain": "modelprofile.com"
|
|
13
|
-
},
|
|
14
|
-
"release": {
|
|
15
|
-
"targets": {
|
|
16
|
-
"git": {
|
|
17
|
-
"enabled": true,
|
|
18
|
-
"remote": "origin",
|
|
19
|
-
"pushBranch": true,
|
|
20
|
-
"pushTags": true
|
|
21
|
-
},
|
|
22
|
-
"npm": {
|
|
23
|
-
"enabled": true,
|
|
24
|
-
"registries": [
|
|
25
|
-
"https://verdaccio.lossless.digital",
|
|
26
|
-
"https://registry.npmjs.org"
|
|
27
|
-
],
|
|
28
|
-
"accessLevel": "public",
|
|
29
|
-
"alreadyPublished": "success"
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|