@zhin.js/host-http 1.2.2 → 1.4.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 (47) hide show
  1. package/lib/access-control.d.ts +41 -3
  2. package/lib/access-control.js +112 -10
  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 +22 -0
  7. package/lib/console-rpc-extended/dispatcher.js +1 -1
  8. package/lib/console-rpc-extended/endpoint-rpc.js +21 -4
  9. package/lib/console-rpc-extended/index.d.ts +1 -1
  10. package/lib/console-rpc-extended/schedule-rpc.d.ts +1 -1
  11. package/lib/console-rpc-extended/schedule-rpc.js +78 -12
  12. package/lib/console-rpc-handlers.d.ts +9 -1
  13. package/lib/console-rpc-handlers.js +108 -12
  14. package/lib/console-rpc-types.d.ts +2 -0
  15. package/lib/console-rpc-types.js +43 -0
  16. package/lib/console-rpc.d.ts +29 -1
  17. package/lib/console-rpc.js +13 -5
  18. package/lib/host-installer.js +10 -5
  19. package/lib/http-host.js +5 -4
  20. package/lib/index.d.ts +6 -3
  21. package/lib/index.js +4 -1
  22. package/lib/node/listener.js +2 -1
  23. package/lib/port.d.ts +27 -0
  24. package/lib/port.js +68 -0
  25. package/lib/project-files.d.ts +7 -0
  26. package/lib/project-files.js +88 -11
  27. package/lib/unknown-rpc.d.ts +12 -0
  28. package/lib/unknown-rpc.js +33 -0
  29. package/package.json +4 -4
  30. package/src/access-control.ts +151 -11
  31. package/src/console-rest-pages-helpers.ts +27 -3
  32. package/src/console-rest-pages.ts +52 -15
  33. package/src/console-rpc-extended/contracts.ts +18 -0
  34. package/src/console-rpc-extended/dispatcher.ts +1 -1
  35. package/src/console-rpc-extended/endpoint-rpc.ts +28 -4
  36. package/src/console-rpc-extended/index.ts +1 -0
  37. package/src/console-rpc-extended/schedule-rpc.ts +67 -3
  38. package/src/console-rpc-handlers.ts +110 -11
  39. package/src/console-rpc-types.ts +45 -0
  40. package/src/console-rpc.ts +39 -5
  41. package/src/host-installer.ts +10 -4
  42. package/src/http-host.ts +6 -2
  43. package/src/index.ts +21 -0
  44. package/src/node/listener.ts +2 -1
  45. package/src/port.ts +60 -0
  46. package/src/project-files.ts +95 -11
  47. package/src/unknown-rpc.ts +44 -0
@@ -7,13 +7,14 @@ import { createServer as createHttpServer, STATUS_CODES, } from 'node:http';
7
7
  import { createServer as createHttpsServer, } from 'node:https';
8
8
  import { WebSocketServer } from 'ws';
9
9
  import { formatCompact, getLogger } from '@zhin.js/logger';
10
+ import { DEFAULT_HTTP_PORT } from '../port.js';
10
11
  import { compileTrustProxy, resolveClientAddress } from '../trust-proxy.js';
11
12
  import { createNodeRequest, nodeRequestHeaders, writeNodeResponse, wrapNodeWebSocket } from './node-http.js';
12
13
  const logger = getLogger('http');
13
14
  /** A Node listener over {@link NodeHttpListenerOptions.handle} / `upgrade`. */
14
15
  export function createNodeHttpListener(options) {
15
16
  const host = options.host ?? '127.0.0.1';
16
- const port = options.port ?? 8086;
17
+ const port = options.port ?? DEFAULT_HTTP_PORT;
17
18
  const secure = options.tls !== undefined;
18
19
  let address;
19
20
  let closed = false;
package/lib/port.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `http.port` (and `http.listeners[].port`): an integer from 1 to 65535, written either as a number
3
+ * or as a `${VAR}` environment reference that expands to one (#1250); `0` keeps its listener meaning
4
+ * (the system picks a free port — tests and embedded use). Anything else is a configuration error:
5
+ * the Host never falls back to the default port for an invalid value.
6
+ */
7
+ /** Port the Host listens on when `http.port` is not configured (same as the scaffold writes). */
8
+ export declare const DEFAULT_HTTP_PORT = 8068;
9
+ /** A whole `${VAR}` / `${VAR:-default}` reference (the form the config schema accepts for a port). */
10
+ export declare const ENVIRONMENT_REFERENCE_PATTERN: RegExp;
11
+ export declare class InvalidHttpPortError extends Error {
12
+ readonly field: string;
13
+ readonly value: unknown;
14
+ readonly reason: string;
15
+ constructor(field: string, value: unknown, reason: string);
16
+ }
17
+ /**
18
+ * Why a stored (unexpanded) port value is invalid, in Chinese; undefined when it is valid.
19
+ * `undefined` / `null` (not configured) are valid; `${VAR}` references are checked after expansion.
20
+ */
21
+ export declare function describeInvalidPortValue(value: unknown): string | undefined;
22
+ /**
23
+ * The listening port from an expanded config value (`${VAR}` already replaced: a digit string is
24
+ * accepted). Returns undefined when not configured; throws {@link InvalidHttpPortError} otherwise.
25
+ * `raw` is the stored value, named in the message when it was an environment reference.
26
+ */
27
+ export declare function parseHttpPort(value: unknown, field: string, raw?: unknown): number | undefined;
package/lib/port.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `http.port` (and `http.listeners[].port`): an integer from 1 to 65535, written either as a number
3
+ * or as a `${VAR}` environment reference that expands to one (#1250); `0` keeps its listener meaning
4
+ * (the system picks a free port — tests and embedded use). Anything else is a configuration error:
5
+ * the Host never falls back to the default port for an invalid value.
6
+ */
7
+ /** Port the Host listens on when `http.port` is not configured (same as the scaffold writes). */
8
+ export const DEFAULT_HTTP_PORT = 8068;
9
+ /** A whole `${VAR}` / `${VAR:-default}` reference (the form the config schema accepts for a port). */
10
+ export const ENVIRONMENT_REFERENCE_PATTERN = /^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::[-=][^}]*)?\}$/u;
11
+ export class InvalidHttpPortError extends Error {
12
+ field;
13
+ value;
14
+ reason;
15
+ constructor(field, value, reason) {
16
+ super(`${field} 无效:${reason}`);
17
+ this.field = field;
18
+ this.value = value;
19
+ this.reason = reason;
20
+ this.name = 'InvalidHttpPortError';
21
+ }
22
+ }
23
+ function show(value) {
24
+ return typeof value === 'string' ? JSON.stringify(value) : String(value);
25
+ }
26
+ /**
27
+ * Why a stored (unexpanded) port value is invalid, in Chinese; undefined when it is valid.
28
+ * `undefined` / `null` (not configured) are valid; `${VAR}` references are checked after expansion.
29
+ */
30
+ export function describeInvalidPortValue(value) {
31
+ if (value === undefined || value === null)
32
+ return undefined;
33
+ if (typeof value === 'number') {
34
+ if (!Number.isInteger(value))
35
+ return `端口必须是整数,当前值 ${value}`;
36
+ if (value < 0 || value > 65_535)
37
+ return `端口必须在 1–65535 之间,当前值 ${value}`;
38
+ return undefined;
39
+ }
40
+ if (typeof value === 'string' && ENVIRONMENT_REFERENCE_PATTERN.test(value.trim()))
41
+ return undefined;
42
+ return `端口必须是 1–65535 的整数或 \${VAR} 形式的环境变量引用,当前值 ${show(value)}`;
43
+ }
44
+ /**
45
+ * The listening port from an expanded config value (`${VAR}` already replaced: a digit string is
46
+ * accepted). Returns undefined when not configured; throws {@link InvalidHttpPortError} otherwise.
47
+ * `raw` is the stored value, named in the message when it was an environment reference.
48
+ */
49
+ export function parseHttpPort(value, field, raw) {
50
+ if (value === undefined || value === null)
51
+ return undefined;
52
+ const fromReference = typeof raw === 'string' && ENVIRONMENT_REFERENCE_PATTERN.test(raw.trim()) ? `(来自 ${raw.trim()})` : '';
53
+ let port;
54
+ if (typeof value === 'number')
55
+ port = value;
56
+ else if (typeof value === 'string' && /^\d+$/u.test(value.trim()))
57
+ port = Number(value.trim());
58
+ else if (typeof value === 'string' && value.trim() === '' && fromReference) {
59
+ throw new InvalidHttpPortError(field, value, `环境变量未设置或为空${fromReference};请在 .env 中设置为 1–65535 的整数`);
60
+ }
61
+ else {
62
+ throw new InvalidHttpPortError(field, value, `端口必须是 1–65535 的整数或 \${VAR} 形式的环境变量引用,当前值 ${show(value)}${fromReference}`);
63
+ }
64
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
65
+ throw new InvalidHttpPortError(field, value, `端口必须在 1–65535 之间,当前值 ${show(value)}${fromReference}`);
66
+ }
67
+ return port;
68
+ }
@@ -7,6 +7,13 @@ export type FileTreeNode = {
7
7
  readonly type: 'file' | 'directory';
8
8
  readonly children?: readonly FileTreeNode[];
9
9
  };
10
+ /** Error text for project-file reads and writes that name a dotenv file (issue #1250). */
11
+ export declare const DOTENV_ACCESS_DENIED_MESSAGE: string;
12
+ /**
13
+ * Whether a project-relative path names a dotenv file or goes through one. Dotenv files hold
14
+ * secrets: the project file manager never lists, reads or writes them (`/ops/env` does).
15
+ */
16
+ export declare function isDotenvProjectPath(relativePath: string): boolean;
10
17
  export declare function isProjectPathAllowed(relativePath: string): boolean;
11
18
  export declare function buildProjectFileTree(projectRoot: string): Promise<FileTreeNode[]>;
12
19
  export declare function readProjectFile(projectRoot: string, relativePath: string): Promise<{
@@ -26,7 +26,13 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
26
26
  'plugin.mjs',
27
27
  'plugin.cjs',
28
28
  'schema.json',
29
+ // Agent workspace bootstrap files (`@zhin.js/agent` bootstrap.ts, timer heartbeat) and the
30
+ // conventional knowledge base directory (`ai.knowledge.baseDir`).
29
31
  'AGENTS.md',
32
+ 'SOUL.md',
33
+ 'TOOLS.md',
34
+ 'HEARTBEAT.md',
35
+ 'knowledge',
30
36
  'tsconfig.json',
31
37
  'zhin.config.yml',
32
38
  'zhin.config.yaml',
@@ -34,25 +40,82 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
34
40
  'config.yml',
35
41
  'config.yaml',
36
42
  'config.json',
37
- '.env',
38
- '.env.development',
39
- '.env.production',
40
43
  'README.md',
41
44
  ]);
42
45
  export const FILE_MANAGER_BLOCKED = Object.freeze(new Set([
43
46
  'node_modules',
44
47
  '.git',
45
- '.env.local',
46
48
  'data',
47
49
  'lib',
48
50
  'dist',
49
51
  'coverage',
50
52
  ]));
53
+ /** Dotenv files the environment page (`/ops/env`, `env:*` RPCs) manages. */
54
+ const ENV_FILE_NAMES = Object.freeze(['.env', '.env.development', '.env.production']);
55
+ /** Error text for project-file reads and writes that name a dotenv file (issue #1250). */
56
+ export const DOTENV_ACCESS_DENIED_MESSAGE = 'Access denied: .env files are not available in the project file manager; '
57
+ + 'manage environment variables on the Environment variables page (/ops/env)';
58
+ // Control characters (NUL included) never name a project file and can truncate a path in native code.
59
+ // eslint-disable-next-line no-control-regex
60
+ const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/u;
61
+ /**
62
+ * Strips trailing dots and spaces (what Windows drops from a file name). A linear scan: the
63
+ * equivalent `/[. ]+$/` backtracks quadratically on long runs of spaces (CodeQL js/polynomial-redos).
64
+ */
65
+ function trimTrailingDotsAndSpaces(value) {
66
+ let end = value.length;
67
+ while (end > 0 && (value[end - 1] === '.' || value[end - 1] === ' '))
68
+ end -= 1;
69
+ return value.slice(0, end);
70
+ }
71
+ /**
72
+ * Whether one path segment names a dotenv file (`.env`, `.env.*`), in every spelling a
73
+ * filesystem may resolve to it: any letter case, trailing dots / spaces (stripped by Windows),
74
+ * an NTFS stream suffix (`.env::$DATA`) and percent-encoding.
75
+ */
76
+ function isDotenvSegment(segment) {
77
+ const variants = [segment];
78
+ try {
79
+ const decoded = decodeURIComponent(segment);
80
+ if (decoded !== segment)
81
+ variants.push(decoded);
82
+ }
83
+ catch { /* not percent-encoded */ }
84
+ return variants.some((variant) => {
85
+ const name = trimTrailingDotsAndSpaces(variant.split(':')[0]).toLowerCase();
86
+ return name === '.env' || name.startsWith('.env.');
87
+ });
88
+ }
89
+ /**
90
+ * Whether a project-relative path names a dotenv file or goes through one. Dotenv files hold
91
+ * secrets: the project file manager never lists, reads or writes them (`/ops/env` does).
92
+ */
93
+ export function isDotenvProjectPath(relativePath) {
94
+ return relativePath.replace(/\\/gu, '/').split('/').some(isDotenvSegment);
95
+ }
96
+ /**
97
+ * Whether one path segment names a dot file or dot directory (`.envrc`, `.npmrc`, `.ssh`, …), also
98
+ * percent-encoded. Such files commonly hold credentials; no entry of {@link FILE_MANAGER_ALLOWED}
99
+ * is one, so the project file manager never reads or writes them (the tree already skips them).
100
+ */
101
+ function isDotSegment(segment) {
102
+ const variants = [segment];
103
+ try {
104
+ const decoded = decodeURIComponent(segment);
105
+ if (decoded !== segment)
106
+ variants.push(decoded);
107
+ }
108
+ catch { /* not percent-encoded */ }
109
+ return variants.some(variant => variant.startsWith('.') && variant !== '.');
110
+ }
51
111
  export function isProjectPathAllowed(relativePath) {
52
112
  if (relativePath.includes('..') || path.isAbsolute(relativePath))
53
113
  return false;
114
+ if (CONTROL_CHARACTERS.test(relativePath) || isDotenvProjectPath(relativePath))
115
+ return false;
54
116
  const normalized = relativePath.replace(/\\/gu, '/').replace(/^\.\//u, '');
55
- if (normalized.split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment)))
117
+ const segments = normalized.split('/');
118
+ if (segments.some((segment) => FILE_MANAGER_BLOCKED.has(segment) || isDotSegment(segment)))
56
119
  return false;
57
120
  return FILE_MANAGER_ALLOWED.some((allowed) => normalized === allowed || normalized.startsWith(`${allowed}/`));
58
121
  }
@@ -84,6 +147,7 @@ export async function buildProjectFileTree(projectRoot) {
84
147
  return tree.sort(compareTreeNodes);
85
148
  }
86
149
  export async function readProjectFile(projectRoot, relativePath) {
150
+ assertNotDotenv(relativePath);
87
151
  const absPath = await resolveAllowedPath(projectRoot, relativePath);
88
152
  if (!absPath)
89
153
  throw new Error(`Access denied: ${relativePath}`);
@@ -99,6 +163,7 @@ export async function readProjectFile(projectRoot, relativePath) {
99
163
  return { content, size: info.size };
100
164
  }
101
165
  export async function saveProjectFile(projectRoot, relativePath, content) {
166
+ assertNotDotenv(relativePath);
102
167
  const absPath = await resolveAllowedPath(projectRoot, relativePath);
103
168
  if (!absPath)
104
169
  throw new Error(`Access denied: ${relativePath}`);
@@ -109,16 +174,28 @@ export async function saveProjectFile(projectRoot, relativePath, content) {
109
174
  await writeFile(checkedPath, content, 'utf8');
110
175
  }
111
176
  export async function listEnvFiles(projectRoot) {
112
- const names = ['.env', '.env.development', '.env.production'];
113
- const result = await Promise.all(names.map(async (name) => {
114
- const resolved = await resolveAllowedPath(projectRoot, name);
177
+ const result = await Promise.all(ENV_FILE_NAMES.map(async (name) => {
178
+ const resolved = await resolveContainedPath(projectRoot, name, isEnvInventoryTarget);
115
179
  return { name, exists: resolved !== null && await pathExists(resolved) };
116
180
  }));
117
181
  return Object.freeze(result);
118
182
  }
183
+ function assertNotDotenv(relativePath) {
184
+ if (isDotenvProjectPath(relativePath))
185
+ throw new Error(DOTENV_ACCESS_DENIED_MESSAGE);
186
+ }
187
+ /** The env inventory only counts a dotenv file (or link) that stays inside the project and off blocked paths. */
188
+ function isEnvInventoryTarget(relativePath) {
189
+ if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath))
190
+ return false;
191
+ return !relativePath.replace(/\\/gu, '/').split('/').some((segment) => FILE_MANAGER_BLOCKED.has(segment));
192
+ }
193
+ function resolveAllowedPath(projectRoot, relativePath) {
194
+ return resolveContainedPath(projectRoot, relativePath, isProjectPathAllowed);
195
+ }
119
196
  /** Resolve existing links, or the nearest existing parent for a new file. */
120
- async function resolveAllowedPath(projectRoot, relativePath) {
121
- if (!isProjectPathAllowed(relativePath))
197
+ async function resolveContainedPath(projectRoot, relativePath, allowed) {
198
+ if (!allowed(relativePath))
122
199
  return null;
123
200
  try {
124
201
  const root = await realpath(projectRoot);
@@ -130,7 +207,7 @@ async function resolveAllowedPath(projectRoot, relativePath) {
130
207
  await lstat(candidate);
131
208
  const resolved = path.resolve(await realpath(candidate), ...missing);
132
209
  const relative = path.relative(root, resolved);
133
- return isProjectPathAllowed(relative) ? resolved : null;
210
+ return allowed(relative) ? resolved : null;
134
211
  }
135
212
  catch (error) {
136
213
  if (error.code !== 'ENOENT')
@@ -0,0 +1,12 @@
1
+ export interface UnknownRpcLog {
2
+ /** Records one unknown RPC; returns true when it was logged (not suppressed). */
3
+ note(type: unknown, now?: number): boolean;
4
+ }
5
+ export type UnknownRpcLogEntry = Readonly<{
6
+ op: 'console_rpc_unknown';
7
+ type: string;
8
+ repeated?: number;
9
+ }>;
10
+ export declare function createUnknownRpcLog(log?: (entry: UnknownRpcLogEntry) => void): UnknownRpcLog;
11
+ /** Process-wide log shared by the Host's RPC routes. */
12
+ export declare const unknownRpcLog: UnknownRpcLog;
@@ -0,0 +1,33 @@
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
+ const logger = getLogger('console-rpc');
8
+ const WINDOW_MS = 10 * 60_000;
9
+ const MAX_TYPES = 256;
10
+ export function createUnknownRpcLog(log = entry => logger.debug(formatCompact({ ...entry }))) {
11
+ const seen = new Map();
12
+ return Object.freeze({
13
+ note(type, now = Date.now()) {
14
+ const name = String(type ?? '').slice(0, 64) || '<empty>';
15
+ const entry = seen.get(name);
16
+ if (entry && now - entry.at < WINDOW_MS) {
17
+ entry.suppressed += 1;
18
+ return false;
19
+ }
20
+ if (!entry && seen.size >= MAX_TYPES) {
21
+ const oldest = seen.keys().next().value;
22
+ if (oldest !== undefined)
23
+ seen.delete(oldest);
24
+ }
25
+ seen.delete(name);
26
+ seen.set(name, { at: now, suppressed: 0 });
27
+ log({ op: 'console_rpc_unknown', type: name, ...(entry?.suppressed ? { repeated: entry.suppressed } : {}) });
28
+ return true;
29
+ },
30
+ });
31
+ }
32
+ /** Process-wide log shared by the Host's RPC routes. */
33
+ export const unknownRpcLog = createUnknownRpcLog();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -25,11 +25,11 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "ws": "^8.21.1",
28
+ "@zhin.js/console-protocol": "1.4.0",
28
29
  "@zhin.js/host-http-contract": "1.2.0",
29
- "@zhin.js/logger": "1.1.3",
30
- "@zhin.js/console-protocol": "1.2.2",
30
+ "@zhin.js/logger": "1.2.0",
31
31
  "@zhin.js/schedule": "1.1.5",
32
- "@zhin.js/plugin-runtime": "1.2.1"
32
+ "@zhin.js/plugin-runtime": "1.3.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^26.1.2",
@@ -1,12 +1,16 @@
1
1
  import {
2
+ CONSOLE_DENIAL_REASONS,
2
3
  CONSOLE_PERMISSION_LIST,
4
+ CONSOLE_UNKNOWN_RPC_CODE,
3
5
  CONSOLE_WRITE_PERMISSIONS,
4
6
  expandPluginPermissions,
5
7
  grantsAllow,
8
+ grantsAllowAlsoRequired,
6
9
  isPluginConsoleRpcType,
7
10
  resolveConsoleHttpPermission,
8
11
  resolveConsoleRpcPermission,
9
12
  resolveConsoleWsPermission,
13
+ type ConsoleDenialReason,
10
14
  type ConsoleGrant,
11
15
  type ConsolePermission,
12
16
  type ConsolePluginPermissionInfo,
@@ -84,6 +88,25 @@ export function isUserPrincipal(principal: ConsolePrincipal | undefined): princi
84
88
  return principal?.kind === 'user' || principal?.kind === 'api-token';
85
89
  }
86
90
 
91
+ /** User id and credential id of the gateway control-socket principal (ADR 0054 §6). */
92
+ export const LOCAL_SOCKET_USER_ID = 'local';
93
+ export const LOCAL_SOCKET_CREDENTIAL_ID = 'local-socket';
94
+
95
+ /**
96
+ * The principal of a request from the project owner's CLI over `.zhin/gateway.sock` (no token).
97
+ * Sessions and API tokens never carry this credential id (theirs are token hashes / token ids).
98
+ */
99
+ export function isLocalSocketPrincipal(principal: ConsolePrincipal | undefined): boolean {
100
+ return principal?.kind === 'user' && principal.userId === LOCAL_SOCKET_USER_ID
101
+ && principal.credentialId === LOCAL_SOCKET_CREDENTIAL_ID;
102
+ }
103
+
104
+ /** A user principal with a global `owner` binding (the local socket principal is one). */
105
+ export function isGlobalOwnerPrincipal(principal: ConsolePrincipal | undefined): boolean {
106
+ return isUserPrincipal(principal)
107
+ && principal.grants.some((grant) => grant.role === 'owner' && grant.scope.kind === 'global');
108
+ }
109
+
87
110
  /**
88
111
  * Whether the request carried an explicit credential: a user session / API token, or the
89
112
  * still-valid bootstrap token (`{ kind: 'bootstrap', open: false }`). An open Host is not explicit.
@@ -155,30 +178,98 @@ export type ConsoleRpcDecision =
155
178
  */
156
179
  delegated?: true;
157
180
  }>
158
- | Readonly<{ allowed: false; code: 'setup_required' | 'unmapped' | 'forbidden'; error: string; permission: ConsolePermission | null }>;
181
+ | Readonly<{
182
+ allowed: false;
183
+ /**
184
+ * `unknown_rpc`: the Host has no handler and no permission entry for the type (zhinjs/zhin#1250).
185
+ * Nothing runs; it is not a security refusal and callers do not audit it (see
186
+ * {@link isAuditedRpcDenial}). Every other code is a refusal that is audited.
187
+ */
188
+ code: 'setup_required' | 'unmapped' | 'forbidden' | typeof CONSOLE_UNKNOWN_RPC_CODE;
189
+ error: string;
190
+ permission: ConsolePermission | null;
191
+ /** Audit `detail.reason`; absent for `unknown_rpc`. */
192
+ reason?: ConsoleDenialReason;
193
+ }>;
194
+
195
+ export interface AuthorizeConsoleRpcOptions {
196
+ /**
197
+ * Whether this Host has a handler for an RPC type that is missing from the permission map. True
198
+ * (or no predicate) keeps the audited `unmapped` refusal — a handler without a permission entry
199
+ * is a development error; false answers `unknown_rpc`. Mapped types never consult it.
200
+ */
201
+ readonly registered?: (type: string) => boolean;
202
+ }
203
+
204
+ /** Whether a refused RPC is written to the audit log (`unknown_rpc` is not). */
205
+ export function isAuditedRpcDenial(decision: ConsoleRpcDecision): boolean {
206
+ return !decision.allowed && decision.code !== CONSOLE_UNKNOWN_RPC_CODE;
207
+ }
208
+
209
+ /** Error text of an unknown RPC: the (truncated) type and nothing else. */
210
+ export function unknownConsoleRpcError(type: unknown): string {
211
+ return `RPC "${String(type ?? '').slice(0, 64)}" 不存在`;
212
+ }
159
213
 
160
214
  /** RPC authorization for one principal and message (deny by default). */
161
- export function authorizeConsoleRpc(principal: ConsolePrincipal, message: Readonly<Record<string, unknown>>): ConsoleRpcDecision {
215
+ export function authorizeConsoleRpc(
216
+ principal: ConsolePrincipal,
217
+ message: Readonly<Record<string, unknown>>,
218
+ options: AuthorizeConsoleRpcOptions = {},
219
+ ): ConsoleRpcDecision {
162
220
  const rule = resolveConsoleRpcPermission(message.type, message);
163
221
  const permission = rule ? (rule.kind === 'exact' ? rule.permission : rule.read) : null;
164
222
  if (principal.kind === 'bootstrap') {
165
223
  if (principal.open) {
166
224
  return Object.freeze({ allowed: true, rule, permission, write: rule ? isWriteRule(rule) : true });
167
225
  }
168
- return Object.freeze({ allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission });
226
+ return Object.freeze({
227
+ allowed: false, code: 'setup_required', error: '请先完成初始化:创建第一个所有者', permission,
228
+ reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED,
229
+ });
169
230
  }
170
231
  if (!rule) {
171
- return Object.freeze({ allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null });
232
+ const type = typeof message.type === 'string' ? message.type : '';
233
+ if (options.registered && !options.registered(type)) {
234
+ return Object.freeze({ allowed: false, code: CONSOLE_UNKNOWN_RPC_CODE, error: unknownConsoleRpcError(message.type), permission: null });
235
+ }
236
+ return Object.freeze({
237
+ allowed: false, code: 'unmapped', error: `RPC "${String(message.type ?? '')}" 未登记权限`, permission: null,
238
+ reason: CONSOLE_DENIAL_REASONS.UNMAPPED_PERMISSION,
239
+ });
172
240
  }
173
241
  if (isPluginConsoleRpcType(message.type)) {
174
242
  return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule), delegated: true });
175
243
  }
176
244
  if (!grantsAllow(principal.grants, permission!, rule.target)) {
177
- return Object.freeze({ allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission });
245
+ return Object.freeze({
246
+ allowed: false, code: 'forbidden', error: `缺少权限 ${permission}`, permission,
247
+ reason: grantsDenialReason(principal.grants, permission!),
248
+ });
249
+ }
250
+ // `alsoRequires` is held globally (same semantics as grantsAllowAlsoRequired): find the first missing one for the denial reason.
251
+ const missing = rule.kind === 'exact' && !grantsAllowAlsoRequired(principal.grants, rule)
252
+ ? rule.alsoRequires?.find(extra => !grantsAllowAlsoRequired(principal.grants, { ...rule, alsoRequires: [extra] }))
253
+ : undefined;
254
+ if (missing) {
255
+ return Object.freeze({
256
+ allowed: false, code: 'forbidden', error: `缺少权限 ${missing}`, permission,
257
+ reason: grantsDenialReason(principal.grants, missing),
258
+ });
178
259
  }
179
260
  return Object.freeze({ allowed: true, rule, permission, write: isWriteRule(rule) });
180
261
  }
181
262
 
263
+ /**
264
+ * Why grants refuse `permission` at a target: `scope_denied` when some binding holds it at another
265
+ * adapter / endpoint, else `insufficient_permission`.
266
+ */
267
+ export function grantsDenialReason(grants: readonly ConsoleGrant[], permission: string): ConsoleDenialReason {
268
+ return grants.some(grant => grant.scope.kind !== 'global' && grant.permissions.includes(permission))
269
+ ? CONSOLE_DENIAL_REASONS.SCOPE_DENIED
270
+ : CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
271
+ }
272
+
182
273
  function isWriteRule(rule: ConsoleRpcPermissionRule): boolean {
183
274
  return rule.kind === 'exact' ? CONSOLE_WRITE_PERMISSIONS.has(rule.permission) : false;
184
275
  }
@@ -192,12 +283,16 @@ export function pluginRpcWriteAllowed(principal: ConsolePrincipal, rule: Console
192
283
  if (principal.kind === 'bootstrap') return principal.open;
193
284
  if (!rule) return false;
194
285
  const permission = rule.kind === 'exact' ? rule.permission : rule.write;
195
- return grantsAllow(principal.grants, permission, rule.target);
286
+ return grantsAllow(principal.grants, permission, rule.target) && grantsAllowAlsoRequired(principal.grants, rule);
196
287
  }
197
288
 
198
289
  export type ConsoleHttpDecision =
199
290
  | Readonly<{ allowed: true; permission: string | null }>
200
- | Readonly<{ allowed: false; status: 401 | 403; code: 'unauthorized' | 'forbidden' | 'setup_required'; error: string; permission: string | null }>;
291
+ | Readonly<{
292
+ allowed: false; status: 401 | 403; code: 'unauthorized' | 'forbidden' | 'setup_required'; error: string; permission: string | null;
293
+ /** Audit `detail.reason`. */
294
+ reason: ConsoleDenialReason;
295
+ }>;
201
296
 
202
297
  /**
203
298
  * HTTP authorization of a route under the API base. `principal` is undefined when no valid
@@ -212,24 +307,35 @@ export function authorizeConsoleHttp(
212
307
  const permission = rule?.permission ?? null;
213
308
  if (rule?.permission === 'public') return Object.freeze({ allowed: true, permission });
214
309
  if (!principal) {
215
- return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission });
310
+ return Object.freeze({ allowed: false, status: 401, code: 'unauthorized', error: 'Invalid or missing token', permission, reason: CONSOLE_DENIAL_REASONS.NOT_AUTHENTICATED });
216
311
  }
217
312
  if (principal.kind === 'bootstrap') {
218
313
  if (principal.open || rule?.permission === 'bootstrap') return Object.freeze({ allowed: true, permission });
219
- return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission });
314
+ return Object.freeze({ allowed: false, status: 403, code: 'setup_required', error: '初始化令牌只能用于创建第一个所有者', permission, reason: CONSOLE_DENIAL_REASONS.SETUP_REQUIRED });
220
315
  }
221
316
  if (!rule) {
222
- return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null });
317
+ return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: 'Route is not registered with a Console permission', permission: null, reason: CONSOLE_DENIAL_REASONS.UNMAPPED_ROUTE });
223
318
  }
224
319
  if (rule.permission === 'rpc' || rule.permission === 'authenticated' || rule.permission === 'bootstrap') {
225
320
  return Object.freeze({ allowed: true, permission });
226
321
  }
227
322
  // Scoped permissions over plain HTTP have no knowable target: a global binding is required.
228
323
  if (grantsAllow(principal.grants, rule.permission)) return Object.freeze({ allowed: true, permission });
229
- return Object.freeze({ allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission });
324
+ return Object.freeze({
325
+ allowed: false, status: 403, code: 'forbidden', error: `缺少权限 ${rule.permission}`, permission,
326
+ reason: CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION,
327
+ });
230
328
  }
231
329
 
232
330
  /** WebSocket upgrade authorization for non-bootstrap principals. */
331
+ /** Audit `detail.reason` of a WebSocket {@link authorizeConsoleWebSocket} refused. */
332
+ export function webSocketDenialReason(principal: ConsolePrincipal, pathname: string): ConsoleDenialReason {
333
+ if (principal.kind === 'bootstrap') return CONSOLE_DENIAL_REASONS.SETUP_REQUIRED;
334
+ return resolveConsoleWsPermission(pathname) === null
335
+ ? CONSOLE_DENIAL_REASONS.UNMAPPED_ROUTE
336
+ : CONSOLE_DENIAL_REASONS.INSUFFICIENT_PERMISSION;
337
+ }
338
+
233
339
  export function authorizeConsoleWebSocket(principal: ConsolePrincipal, pathname: string): boolean {
234
340
  // Only the Console's own sockets are host-authenticated; plugin sockets use `auth: 'protocol'`.
235
341
  const permission = resolveConsoleWsPermission(pathname);
@@ -317,9 +423,43 @@ export function rpcAuditDetail(
317
423
  type: String(message.type ?? '').slice(0, 256),
318
424
  keys: Object.freeze([...new Set([...keys, ...data])].slice(0, 64).map(key => key.slice(0, 128))),
319
425
  ...databaseStatementDetail(message, reply, options),
426
+ ...envDetail(message),
320
427
  }));
321
428
  }
322
429
 
430
+ const auditName = (value: unknown, max: number): string | undefined =>
431
+ typeof value === 'string' && value ? value.slice(0, max) : undefined;
432
+
433
+ /**
434
+ * Which dotenv file and variable names an `env:reveal` / `env:save` touched (issue #1250). Never a
435
+ * value: `changes[].value` and `content` are not read.
436
+ */
437
+ function envDetail(message: Readonly<Record<string, unknown>>): Record<string, unknown> {
438
+ if (message.type !== 'env:reveal' && message.type !== 'env:save') return {};
439
+ const data = message.data && typeof message.data === 'object' && !Array.isArray(message.data) ? message.data as Record<string, unknown> : {};
440
+ const pick = (key: string) => message[key] ?? data[key];
441
+ const file = auditName(pick('filename'), 64);
442
+ if (message.type === 'env:reveal') {
443
+ const key = auditName(pick('key'), 128);
444
+ return { ...(file ? { file } : {}), ...(key ? { key } : {}) };
445
+ }
446
+ const changes = Array.isArray(pick('changes')) ? pick('changes') as unknown[] : undefined;
447
+ if (!changes) return { ...(file ? { file } : {}), ...(pick('content') !== undefined ? { mode: 'replace' } : {}) };
448
+ const set: string[] = [];
449
+ const deleted: string[] = [];
450
+ for (const change of changes.slice(0, 64)) {
451
+ if (!change || typeof change !== 'object') continue;
452
+ const key = auditName((change as Record<string, unknown>).key, 128);
453
+ if (key) ((change as Record<string, unknown>).delete === true ? deleted : set).push(key);
454
+ }
455
+ return {
456
+ ...(file ? { file } : {}),
457
+ set: Object.freeze(set),
458
+ deleted: Object.freeze(deleted),
459
+ ...(changes.length > 64 ? { truncated: true } : {}),
460
+ };
461
+ }
462
+
323
463
  export function rpcAuditTarget(rule: ConsoleRpcPermissionRule | null): string | undefined {
324
464
  if (!rule) return undefined;
325
465
  const target = rule.target;