@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.
Files changed (44) hide show
  1. package/lib/access-control.d.ts +50 -5
  2. package/lib/access-control.js +167 -13
  3. package/lib/console-rest-pages-helpers.js +24 -2
  4. package/lib/console-rest-pages.d.ts +4 -1
  5. package/lib/console-rest-pages.js +49 -23
  6. package/lib/console-rpc-extended/contracts.d.ts +92 -15
  7. package/lib/console-rpc-extended/dispatcher.js +10 -3
  8. package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
  9. package/lib/console-rpc-extended/index.d.ts +2 -1
  10. package/lib/console-rpc-extended/index.js +1 -0
  11. package/lib/console-rpc-extended/login-rpc.d.ts +25 -2
  12. package/lib/console-rpc-extended/login-rpc.js +276 -16
  13. package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
  14. package/lib/console-rpc-extended/schedule-rpc.js +78 -12
  15. package/lib/console-rpc-handlers.d.ts +11 -1
  16. package/lib/console-rpc-handlers.js +221 -12
  17. package/lib/console-rpc-types.d.ts +2 -0
  18. package/lib/console-rpc-types.js +43 -0
  19. package/lib/console-rpc.d.ts +29 -1
  20. package/lib/console-rpc.js +33 -7
  21. package/lib/http-host.js +5 -4
  22. package/lib/index.d.ts +5 -3
  23. package/lib/index.js +4 -2
  24. package/lib/project-files.d.ts +7 -0
  25. package/lib/project-files.js +88 -11
  26. package/lib/unknown-rpc.d.ts +12 -0
  27. package/lib/unknown-rpc.js +33 -0
  28. package/package.json +3 -3
  29. package/src/access-control.ts +209 -14
  30. package/src/console-rest-pages-helpers.ts +27 -3
  31. package/src/console-rest-pages.ts +52 -15
  32. package/src/console-rpc-extended/contracts.ts +83 -16
  33. package/src/console-rpc-extended/dispatcher.ts +10 -3
  34. package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
  35. package/src/console-rpc-extended/index.ts +4 -0
  36. package/src/console-rpc-extended/login-rpc.ts +250 -17
  37. package/src/console-rpc-extended/schedule-rpc.ts +67 -3
  38. package/src/console-rpc-handlers.ts +198 -11
  39. package/src/console-rpc-types.ts +45 -0
  40. package/src/console-rpc.ts +63 -7
  41. package/src/http-host.ts +6 -2
  42. package/src/index.ts +15 -0
  43. package/src/project-files.ts +95 -11
  44. package/src/unknown-rpc.ts +44 -0
@@ -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
- if (normalized.split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment))) return false;
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 names = ['.env', '.env.development', '.env.production'] as const;
126
- const result = await Promise.all(names.map(async (name) => {
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 resolveAllowedPath(projectRoot: string, relativePath: string): Promise<string | null> {
135
- if (!isProjectPathAllowed(relativePath)) return null;
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 isProjectPathAllowed(relative) ? resolved : null;
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();