docs: absorb documentation PR sweep

This commit is contained in:
Peter Steinberger
2026-05-23 10:23:22 +01:00
parent 6b04170167
commit 2c536a8626
39 changed files with 455 additions and 71 deletions
File diff suppressed because one or more lines are too long
+24 -10
View File
@@ -7,6 +7,7 @@ import {
CircularIncludeError,
ConfigIncludeError,
MAX_INCLUDE_FILE_BYTES,
MAX_INCLUDE_PATH_LENGTH,
deepMerge,
type IncludeResolver,
resolveConfigIncludes,
@@ -576,17 +577,30 @@ describe("security: path traversal protection (CWE-22)", () => {
});
describe("edge cases", () => {
it.each([
{ includePath: "./file\x00.json", expectedError: undefined },
{ includePath: "//etc/passwd", expectedError: ConfigIncludeError },
] as const)("rejects malformed include path $includePath", ({ includePath, expectedError }) => {
const obj = { $include: includePath };
if (expectedError) {
expectResolveIncludeError(() => resolve(obj, {}));
return;
it("rejects malformed include paths", () => {
const cases = [
{ includePath: "./file\x00.json", pattern: /null bytes?/i },
{ includePath: "./a\x00b.json", pattern: /null bytes?/i },
{ includePath: "//etc/passwd", pattern: /escapes config directory/ },
] as const;
for (const testCase of cases) {
const obj = { $include: testCase.includePath };
expectResolveIncludeError(() => resolve(obj, {}), testCase.pattern);
}
// Path with null byte should be rejected or handled safely.
expectResolveIncludeError(() => resolve(obj, {}));
});
it("rejects include path at or over maximum length (>= MAX_INCLUDE_PATH_LENGTH)", () => {
const overLimit = "a".repeat(MAX_INCLUDE_PATH_LENGTH + 1);
expectResolveIncludeError(() => resolve({ $include: overLimit }, {}), /maximum length/);
// Boundary: length exactly 4096 must be rejected (Linux PATH_MAX includes NUL)
const atLimit = "b".repeat(MAX_INCLUDE_PATH_LENGTH);
expectResolveIncludeError(() => resolve({ $include: atLimit }, {}), /maximum length/);
});
it("accepts include path at or under maximum length when file exists", () => {
const shortPath = configPath("base.json");
const files = { [shortPath]: { ok: true } };
expect(resolve({ $include: shortPath }, files)).toEqual({ ok: true });
});
it("allows child include when config is at filesystem root", () => {
+20
View File
@@ -22,6 +22,9 @@ export const INCLUDE_KEY = "$include";
export const MAX_INCLUDE_DEPTH = 10;
export const MAX_INCLUDE_FILE_BYTES = 2 * 1024 * 1024;
/** Maximum length for $include path and resolved path (CWE-22 hardening). */
export const MAX_INCLUDE_PATH_LENGTH = 4096;
// ============================================================================
// Types
// ============================================================================
@@ -212,12 +215,29 @@ class IncludeProcessor {
}
private resolvePath(includePath: string): { resolvedPath: string; root: IncludeRoot } {
if (includePath.includes("\0")) {
throw new ConfigIncludeError("Include path must not contain null bytes", includePath);
}
if (includePath.length >= MAX_INCLUDE_PATH_LENGTH) {
throw new ConfigIncludeError(
`Include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`,
includePath,
);
}
const configDir = path.dirname(this.basePath);
const resolved = path.isAbsolute(includePath)
? includePath
: path.resolve(configDir, includePath);
const normalized = path.normalize(resolved);
if (normalized.length >= MAX_INCLUDE_PATH_LENGTH) {
throw new ConfigIncludeError(
`Resolved include path exceeds maximum length (${MAX_INCLUDE_PATH_LENGTH} characters)`,
includePath,
);
}
// SECURITY: Reject paths outside the config directory and any caller-allowed
// roots (CWE-22: Path Traversal). Allowed roots come from
// OPENCLAW_INCLUDE_ROOTS and let operators opt into shared include trees
+2
View File
@@ -18,6 +18,8 @@ export type SignalAccountConfig = CommonChannelMessagingConfig & {
account?: string;
/** Optional account UUID for signal-cli (used for loop protection). */
accountUuid?: string;
/** Optional signal-cli config directory path (passed as --config). */
configPath?: string;
/** Optional full base URL for signal-cli HTTP daemon. */
httpUrl?: string;
/** HTTP host for signal-cli daemon (default 127.0.0.1). */
+2 -2
View File
@@ -276,7 +276,7 @@ export type TelegramGroupConfig = {
toolsBySender?: GroupToolPolicyBySenderConfig;
/** If specified, only load these skills for this group (when no topic). Omit = all skills; empty = no skills. */
skills?: string[];
/** Per-topic configuration (key is message_thread_id as string) */
/** Per-topic configuration (key is message_thread_id as string, or "*" for topic defaults). */
topics?: Record<string, TelegramTopicConfig>;
/** If false, disable the bot for this group (and its topics). */
enabled?: boolean;
@@ -311,7 +311,7 @@ export type TelegramDirectConfig = {
toolsBySender?: GroupToolPolicyBySenderConfig;
/** If specified, only load these skills for this DM (when no topic). Omit = all skills; empty = no skills. */
skills?: string[];
/** Per-topic configuration for DM topics (key is message_thread_id as string) */
/** Per-topic configuration for DM topics (key is message_thread_id as string, or "*" for topic defaults). */
topics?: Record<string, TelegramTopicConfig>;
/** If false, disable the bot for this DM (and its topics). */
enabled?: boolean;
+1
View File
@@ -1204,6 +1204,7 @@ export const SignalAccountSchemaBase = z
configWrites: z.boolean().optional(),
account: z.string().optional(),
accountUuid: z.string().optional(),
configPath: z.string().optional(),
httpUrl: z.string().optional(),
httpHost: z.string().optional(),
httpPort: z.number().int().positive().optional(),