@zhin.js/host-http 1.2.2 → 1.3.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/lib/access-control.d.ts +31 -3
- package/lib/access-control.js +96 -10
- package/lib/console-rest-pages-helpers.js +24 -2
- package/lib/console-rest-pages.d.ts +4 -1
- package/lib/console-rest-pages.js +49 -23
- package/lib/console-rpc-extended/contracts.d.ts +22 -0
- package/lib/console-rpc-extended/dispatcher.js +1 -1
- package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
- package/lib/console-rpc-extended/index.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
- package/lib/console-rpc-extended/schedule-rpc.js +78 -12
- package/lib/console-rpc-handlers.d.ts +9 -1
- package/lib/console-rpc-handlers.js +97 -10
- package/lib/console-rpc-types.d.ts +2 -0
- package/lib/console-rpc-types.js +43 -0
- package/lib/console-rpc.d.ts +29 -1
- package/lib/console-rpc.js +5 -4
- package/lib/http-host.js +5 -4
- package/lib/index.d.ts +5 -3
- package/lib/index.js +3 -1
- package/lib/project-files.d.ts +7 -0
- package/lib/project-files.js +88 -11
- package/lib/unknown-rpc.d.ts +12 -0
- package/lib/unknown-rpc.js +33 -0
- package/package.json +3 -3
- package/src/access-control.ts +132 -11
- package/src/console-rest-pages-helpers.ts +27 -3
- package/src/console-rest-pages.ts +52 -15
- package/src/console-rpc-extended/contracts.ts +18 -0
- package/src/console-rpc-extended/dispatcher.ts +1 -1
- package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
- package/src/console-rpc-extended/index.ts +1 -0
- package/src/console-rpc-extended/schedule-rpc.ts +67 -3
- package/src/console-rpc-handlers.ts +99 -9
- package/src/console-rpc-types.ts +45 -0
- package/src/console-rpc.ts +31 -4
- package/src/http-host.ts +6 -2
- package/src/index.ts +10 -0
- package/src/project-files.ts +95 -11
- package/src/unknown-rpc.ts +44 -0
package/src/http-host.ts
CHANGED
|
@@ -30,8 +30,10 @@ import {
|
|
|
30
30
|
authorizeConsoleHttp,
|
|
31
31
|
authorizeConsoleWebSocket,
|
|
32
32
|
requestMeta,
|
|
33
|
+
webSocketDenialReason,
|
|
33
34
|
type ConsolePrincipal,
|
|
34
35
|
} from './access-control.js';
|
|
36
|
+
import type { ConsoleDenialReason } from '@zhin.js/console-protocol';
|
|
35
37
|
import type { HttpHostAddress } from './host-options.js';
|
|
36
38
|
|
|
37
39
|
export type { HttpHostAddress } from './host-options.js';
|
|
@@ -301,7 +303,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
301
303
|
);
|
|
302
304
|
if (!principal) return undefined;
|
|
303
305
|
if (!authorizeConsoleWebSocket(principal, pathname)) {
|
|
304
|
-
audit(principal, remoteAddress, `ws:${pathname}`, 'denied');
|
|
306
|
+
audit(principal, remoteAddress, `ws:${pathname}`, 'denied', webSocketDenialReason(principal, pathname));
|
|
305
307
|
return undefined;
|
|
306
308
|
}
|
|
307
309
|
return principal;
|
|
@@ -312,6 +314,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
312
314
|
remoteAddress: string | undefined,
|
|
313
315
|
action: string,
|
|
314
316
|
outcome: 'ok' | 'denied' | 'error',
|
|
317
|
+
reason?: ConsoleDenialReason,
|
|
315
318
|
): void {
|
|
316
319
|
const sink = tokenRegistry.accessProvider?.audit;
|
|
317
320
|
if (!sink) return;
|
|
@@ -320,6 +323,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
320
323
|
actor: auditActor(principal),
|
|
321
324
|
action,
|
|
322
325
|
outcome,
|
|
326
|
+
...(reason ? { detail: { reason } } : {}),
|
|
323
327
|
...(remoteAddress ? { ip: remoteAddress } : {}),
|
|
324
328
|
});
|
|
325
329
|
} catch { /* audit never breaks a request */ }
|
|
@@ -508,7 +512,7 @@ export function createHttpRouter(options: HttpRouterOptions = {}): HttpRouter {
|
|
|
508
512
|
const relative = pathname === apiBase ? '/' : pathname.slice(apiBase.length);
|
|
509
513
|
const decision = authorizeConsoleHttp(principal, method, relative);
|
|
510
514
|
if (!decision.allowed) {
|
|
511
|
-
if (principal) audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied');
|
|
515
|
+
if (principal) audit(principal, remoteAddress, `http:${method} ${relative}`, 'denied', decision.reason);
|
|
512
516
|
return { ok: false, status: decision.status, code: decision.code, error: decision.error };
|
|
513
517
|
}
|
|
514
518
|
return { ok: true, ...(principal ? { principal } : {}) };
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,11 @@ export {
|
|
|
36
36
|
authorizeConsoleRpc,
|
|
37
37
|
authorizeConsoleWebSocket,
|
|
38
38
|
globalPermissions,
|
|
39
|
+
grantsDenialReason,
|
|
40
|
+
isAuditedRpcDenial,
|
|
41
|
+
unknownConsoleRpcError,
|
|
42
|
+
webSocketDenialReason,
|
|
43
|
+
type AuthorizeConsoleRpcOptions,
|
|
39
44
|
isUserPrincipal,
|
|
40
45
|
isExplicitConsolePrincipal,
|
|
41
46
|
pluginRpcWriteAllowed,
|
|
@@ -93,6 +98,8 @@ export {
|
|
|
93
98
|
type HttpResponseStream,
|
|
94
99
|
type HttpResponseStreamOptions,
|
|
95
100
|
} from '@zhin.js/host-http-contract';
|
|
101
|
+
export { isRuntimeConsoleRpcType, RUNTIME_CONSOLE_RPC_TYPES } from './console-rpc-types.js';
|
|
102
|
+
export { createUnknownRpcLog, unknownRpcLog, type UnknownRpcLog, type UnknownRpcLogEntry } from './unknown-rpc.js';
|
|
96
103
|
export {
|
|
97
104
|
dispatchRuntimeConsoleRpc,
|
|
98
105
|
pickRpcReply,
|
|
@@ -106,6 +113,8 @@ export {
|
|
|
106
113
|
type PluginUpdatePlan,
|
|
107
114
|
type PluginManagementPort,
|
|
108
115
|
type PluginManagementState,
|
|
116
|
+
type PluginInspectOptions,
|
|
117
|
+
type PluginDependencyCheck,
|
|
109
118
|
type PluginVerificationResult,
|
|
110
119
|
type PluginVerificationPlan,
|
|
111
120
|
type PluginConfigValidation,
|
|
@@ -133,6 +142,7 @@ export {
|
|
|
133
142
|
dispatchExtendedConsoleRpc,
|
|
134
143
|
type ConsoleRpcExtendedCtx,
|
|
135
144
|
type ConsoleScheduleEngine,
|
|
145
|
+
type ConsoleScheduleTarget,
|
|
136
146
|
type ConsoleWorkroomProfileControlPort,
|
|
137
147
|
type ConsoleWorkroomKnowledgeControlPort,
|
|
138
148
|
type ExtendedRpcResult,
|
package/src/project-files.ts
CHANGED
|
@@ -27,7 +27,13 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
|
|
|
27
27
|
'plugin.mjs',
|
|
28
28
|
'plugin.cjs',
|
|
29
29
|
'schema.json',
|
|
30
|
+
// Agent workspace bootstrap files (`@zhin.js/agent` bootstrap.ts, timer heartbeat) and the
|
|
31
|
+
// conventional knowledge base directory (`ai.knowledge.baseDir`).
|
|
30
32
|
'AGENTS.md',
|
|
33
|
+
'SOUL.md',
|
|
34
|
+
'TOOLS.md',
|
|
35
|
+
'HEARTBEAT.md',
|
|
36
|
+
'knowledge',
|
|
31
37
|
'tsconfig.json',
|
|
32
38
|
'zhin.config.yml',
|
|
33
39
|
'zhin.config.yaml',
|
|
@@ -35,16 +41,12 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
|
|
|
35
41
|
'config.yml',
|
|
36
42
|
'config.yaml',
|
|
37
43
|
'config.json',
|
|
38
|
-
'.env',
|
|
39
|
-
'.env.development',
|
|
40
|
-
'.env.production',
|
|
41
44
|
'README.md',
|
|
42
45
|
]);
|
|
43
46
|
|
|
44
47
|
export const FILE_MANAGER_BLOCKED = Object.freeze(new Set([
|
|
45
48
|
'node_modules',
|
|
46
49
|
'.git',
|
|
47
|
-
'.env.local',
|
|
48
50
|
'data',
|
|
49
51
|
'lib',
|
|
50
52
|
'dist',
|
|
@@ -58,10 +60,73 @@ export type FileTreeNode = {
|
|
|
58
60
|
readonly children?: readonly FileTreeNode[];
|
|
59
61
|
};
|
|
60
62
|
|
|
63
|
+
/** Dotenv files the environment page (`/ops/env`, `env:*` RPCs) manages. */
|
|
64
|
+
const ENV_FILE_NAMES = Object.freeze(['.env', '.env.development', '.env.production'] as const);
|
|
65
|
+
|
|
66
|
+
/** Error text for project-file reads and writes that name a dotenv file (issue #1250). */
|
|
67
|
+
export const DOTENV_ACCESS_DENIED_MESSAGE =
|
|
68
|
+
'Access denied: .env files are not available in the project file manager; '
|
|
69
|
+
+ 'manage environment variables on the Environment variables page (/ops/env)';
|
|
70
|
+
|
|
71
|
+
// Control characters (NUL included) never name a project file and can truncate a path in native code.
|
|
72
|
+
// eslint-disable-next-line no-control-regex
|
|
73
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Strips trailing dots and spaces (what Windows drops from a file name). A linear scan: the
|
|
77
|
+
* equivalent `/[. ]+$/` backtracks quadratically on long runs of spaces (CodeQL js/polynomial-redos).
|
|
78
|
+
*/
|
|
79
|
+
function trimTrailingDotsAndSpaces(value: string): string {
|
|
80
|
+
let end = value.length;
|
|
81
|
+
while (end > 0 && (value[end - 1] === '.' || value[end - 1] === ' ')) end -= 1;
|
|
82
|
+
return value.slice(0, end);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Whether one path segment names a dotenv file (`.env`, `.env.*`), in every spelling a
|
|
87
|
+
* filesystem may resolve to it: any letter case, trailing dots / spaces (stripped by Windows),
|
|
88
|
+
* an NTFS stream suffix (`.env::$DATA`) and percent-encoding.
|
|
89
|
+
*/
|
|
90
|
+
function isDotenvSegment(segment: string): boolean {
|
|
91
|
+
const variants = [segment];
|
|
92
|
+
try {
|
|
93
|
+
const decoded = decodeURIComponent(segment);
|
|
94
|
+
if (decoded !== segment) variants.push(decoded);
|
|
95
|
+
} catch { /* not percent-encoded */ }
|
|
96
|
+
return variants.some((variant) => {
|
|
97
|
+
const name = trimTrailingDotsAndSpaces(variant.split(':')[0]!).toLowerCase();
|
|
98
|
+
return name === '.env' || name.startsWith('.env.');
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether a project-relative path names a dotenv file or goes through one. Dotenv files hold
|
|
104
|
+
* secrets: the project file manager never lists, reads or writes them (`/ops/env` does).
|
|
105
|
+
*/
|
|
106
|
+
export function isDotenvProjectPath(relativePath: string): boolean {
|
|
107
|
+
return relativePath.replace(/\\/gu, '/').split('/').some(isDotenvSegment);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Whether one path segment names a dot file or dot directory (`.envrc`, `.npmrc`, `.ssh`, …), also
|
|
112
|
+
* percent-encoded. Such files commonly hold credentials; no entry of {@link FILE_MANAGER_ALLOWED}
|
|
113
|
+
* is one, so the project file manager never reads or writes them (the tree already skips them).
|
|
114
|
+
*/
|
|
115
|
+
function isDotSegment(segment: string): boolean {
|
|
116
|
+
const variants = [segment];
|
|
117
|
+
try {
|
|
118
|
+
const decoded = decodeURIComponent(segment);
|
|
119
|
+
if (decoded !== segment) variants.push(decoded);
|
|
120
|
+
} catch { /* not percent-encoded */ }
|
|
121
|
+
return variants.some(variant => variant.startsWith('.') && variant !== '.');
|
|
122
|
+
}
|
|
123
|
+
|
|
61
124
|
export function isProjectPathAllowed(relativePath: string): boolean {
|
|
62
125
|
if (relativePath.includes('..') || path.isAbsolute(relativePath)) return false;
|
|
126
|
+
if (CONTROL_CHARACTERS.test(relativePath) || isDotenvProjectPath(relativePath)) return false;
|
|
63
127
|
const normalized = relativePath.replace(/\\/gu, '/').replace(/^\.\//u, '');
|
|
64
|
-
|
|
128
|
+
const segments = normalized.split('/');
|
|
129
|
+
if (segments.some((segment) => FILE_MANAGER_BLOCKED.has(segment) || isDotSegment(segment))) return false;
|
|
65
130
|
return FILE_MANAGER_ALLOWED.some(
|
|
66
131
|
(allowed) => normalized === allowed || normalized.startsWith(`${allowed}/`),
|
|
67
132
|
);
|
|
@@ -94,6 +159,7 @@ export async function readProjectFile(
|
|
|
94
159
|
projectRoot: string,
|
|
95
160
|
relativePath: string,
|
|
96
161
|
): Promise<{ content: string; size: number }> {
|
|
162
|
+
assertNotDotenv(relativePath);
|
|
97
163
|
const absPath = await resolveAllowedPath(projectRoot, relativePath);
|
|
98
164
|
if (!absPath) throw new Error(`Access denied: ${relativePath}`);
|
|
99
165
|
if (!(await pathExists(absPath))) throw new Error(`File not found: ${relativePath}`);
|
|
@@ -111,6 +177,7 @@ export async function saveProjectFile(
|
|
|
111
177
|
relativePath: string,
|
|
112
178
|
content: string,
|
|
113
179
|
): Promise<void> {
|
|
180
|
+
assertNotDotenv(relativePath);
|
|
114
181
|
const absPath = await resolveAllowedPath(projectRoot, relativePath);
|
|
115
182
|
if (!absPath) throw new Error(`Access denied: ${relativePath}`);
|
|
116
183
|
await mkdir(path.dirname(absPath), { recursive: true });
|
|
@@ -122,17 +189,34 @@ export async function saveProjectFile(
|
|
|
122
189
|
export async function listEnvFiles(
|
|
123
190
|
projectRoot: string,
|
|
124
191
|
): Promise<readonly { name: string; exists: boolean }[]> {
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
const resolved = await resolveAllowedPath(projectRoot, name);
|
|
192
|
+
const result = await Promise.all(ENV_FILE_NAMES.map(async (name) => {
|
|
193
|
+
const resolved = await resolveContainedPath(projectRoot, name, isEnvInventoryTarget);
|
|
128
194
|
return { name, exists: resolved !== null && await pathExists(resolved) };
|
|
129
195
|
}));
|
|
130
196
|
return Object.freeze(result);
|
|
131
197
|
}
|
|
132
198
|
|
|
199
|
+
function assertNotDotenv(relativePath: string): void {
|
|
200
|
+
if (isDotenvProjectPath(relativePath)) throw new Error(DOTENV_ACCESS_DENIED_MESSAGE);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The env inventory only counts a dotenv file (or link) that stays inside the project and off blocked paths. */
|
|
204
|
+
function isEnvInventoryTarget(relativePath: string): boolean {
|
|
205
|
+
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) return false;
|
|
206
|
+
return !relativePath.replace(/\\/gu, '/').split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function resolveAllowedPath(projectRoot: string, relativePath: string): Promise<string | null> {
|
|
210
|
+
return resolveContainedPath(projectRoot, relativePath, isProjectPathAllowed);
|
|
211
|
+
}
|
|
212
|
+
|
|
133
213
|
/** Resolve existing links, or the nearest existing parent for a new file. */
|
|
134
|
-
async function
|
|
135
|
-
|
|
214
|
+
async function resolveContainedPath(
|
|
215
|
+
projectRoot: string,
|
|
216
|
+
relativePath: string,
|
|
217
|
+
allowed: (relativePath: string) => boolean,
|
|
218
|
+
): Promise<string | null> {
|
|
219
|
+
if (!allowed(relativePath)) return null;
|
|
136
220
|
try {
|
|
137
221
|
const root = await realpath(projectRoot);
|
|
138
222
|
let candidate = path.resolve(root, relativePath);
|
|
@@ -143,7 +227,7 @@ async function resolveAllowedPath(projectRoot: string, relativePath: string): Pr
|
|
|
143
227
|
await lstat(candidate);
|
|
144
228
|
const resolved = path.resolve(await realpath(candidate), ...missing);
|
|
145
229
|
const relative = path.relative(root, resolved);
|
|
146
|
-
return
|
|
230
|
+
return allowed(relative) ? resolved : null;
|
|
147
231
|
} catch (error) {
|
|
148
232
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') return null;
|
|
149
233
|
// lstat succeeds for dangling symlinks even though realpath fails.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unknown Console RPC (zhinjs/zhin#1250): not a security refusal, so it is never audited. A
|
|
3
|
+
* Console that polls an RPC this Host lacks would otherwise fill the audit log; instead each type
|
|
4
|
+
* is logged at debug level at most once per window, with how often it was seen meanwhile.
|
|
5
|
+
*/
|
|
6
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
7
|
+
|
|
8
|
+
const logger = getLogger('console-rpc');
|
|
9
|
+
const WINDOW_MS = 10 * 60_000;
|
|
10
|
+
const MAX_TYPES = 256;
|
|
11
|
+
|
|
12
|
+
interface Seen { at: number; suppressed: number }
|
|
13
|
+
|
|
14
|
+
export interface UnknownRpcLog {
|
|
15
|
+
/** Records one unknown RPC; returns true when it was logged (not suppressed). */
|
|
16
|
+
note(type: unknown, now?: number): boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type UnknownRpcLogEntry = Readonly<{ op: 'console_rpc_unknown'; type: string; repeated?: number }>;
|
|
20
|
+
|
|
21
|
+
export function createUnknownRpcLog(log: (entry: UnknownRpcLogEntry) => void = entry => logger.debug(formatCompact({ ...entry }))): UnknownRpcLog {
|
|
22
|
+
const seen = new Map<string, Seen>();
|
|
23
|
+
return Object.freeze({
|
|
24
|
+
note(type: unknown, now = Date.now()): boolean {
|
|
25
|
+
const name = String(type ?? '').slice(0, 64) || '<empty>';
|
|
26
|
+
const entry = seen.get(name);
|
|
27
|
+
if (entry && now - entry.at < WINDOW_MS) {
|
|
28
|
+
entry.suppressed += 1;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (!entry && seen.size >= MAX_TYPES) {
|
|
32
|
+
const oldest = seen.keys().next().value;
|
|
33
|
+
if (oldest !== undefined) seen.delete(oldest);
|
|
34
|
+
}
|
|
35
|
+
seen.delete(name);
|
|
36
|
+
seen.set(name, { at: now, suppressed: 0 });
|
|
37
|
+
log({ op: 'console_rpc_unknown', type: name, ...(entry?.suppressed ? { repeated: entry.suppressed } : {}) });
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Process-wide log shared by the Host's RPC routes. */
|
|
44
|
+
export const unknownRpcLog: UnknownRpcLog = createUnknownRpcLog();
|