@zhin.js/host-http 1.2.1 → 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 +50 -5
- package/lib/access-control.js +167 -13
- 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 +92 -15
- package/lib/console-rpc-extended/dispatcher.js +10 -3
- package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
- package/lib/console-rpc-extended/index.d.ts +2 -1
- package/lib/console-rpc-extended/index.js +1 -0
- package/lib/console-rpc-extended/login-rpc.d.ts +25 -2
- package/lib/console-rpc-extended/login-rpc.js +276 -16
- 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 +11 -1
- package/lib/console-rpc-handlers.js +221 -12
- 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 +33 -7
- package/lib/http-host.js +5 -4
- package/lib/index.d.ts +5 -3
- package/lib/index.js +4 -2
- 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 +209 -14
- package/src/console-rest-pages-helpers.ts +27 -3
- package/src/console-rest-pages.ts +52 -15
- package/src/console-rpc-extended/contracts.ts +83 -16
- package/src/console-rpc-extended/dispatcher.ts +10 -3
- package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
- package/src/console-rpc-extended/index.ts +4 -0
- package/src/console-rpc-extended/login-rpc.ts +250 -17
- package/src/console-rpc-extended/schedule-rpc.ts +67 -3
- package/src/console-rpc-handlers.ts +198 -11
- package/src/console-rpc-types.ts +45 -0
- package/src/console-rpc.ts +63 -7
- package/src/http-host.ts +6 -2
- package/src/index.ts +15 -0
- package/src/project-files.ts +95 -11
- package/src/unknown-rpc.ts +44 -0
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();
|