diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 93806f47fb1..a8790214328 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -55,6 +55,7 @@ jobs: # WARNING: KEEP MANUAL BACKFILLS GATED BY THE docker-release ENVIRONMENT. runs-on: ubuntu-24.04 environment: docker-release + permissions: {} steps: - name: Approve Docker backfill env: diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 886a9624f4d..eb75092bcf5 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -527,6 +527,7 @@ jobs: - qa_live_telegram_release_checks if: always() runs-on: ubuntu-24.04 + permissions: {} timeout-minutes: 5 steps: - name: Verify release check results diff --git a/CHANGELOG.md b/CHANGELOG.md index c767c359d06..bba48accffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Plugins/startup: parse strict JSON plugin manifests with native JSON first and keep JSON5 as the compatibility fallback, reducing manifest registry CPU during Gateway boot and CLI startup. Fixes #73011. Thanks @jasonftl. - CLI/models: keep route-first `models status --json` stdout reserved for the JSON payload by routing auth-profile and startup diagnostics to stderr. Fixes #72962. Thanks @vishutdhar. - Gateway/runtime: keep dirty-tree status calls from rebuilding live `dist`, clear stale task and restart state across in-process restarts, retry transient Discord lazy imports, and let channel startup continue after slow model warmup so browser, Discord, and voice-call sidecars come online. Thanks @vincentkoc. +- Security/CodeQL: replace file SecretRef id gateway schema regex validation with segment-aligned predicates and set empty permissions on release summary/backfill jobs so the narrowed CodeQL profile stays clean. Thanks @vincentkoc. - Sessions: ignore future-dated session activity timestamps during reset freshness checks and cap future `updatedAt` values at the merge boundary so clock-skewed messages cannot keep stale sessions alive forever. Fixes #72989. Thanks @martingarramon. - Sessions: apply search, activity filters, and limits before gateway row enrichment so bounded session lists avoid scanning discarded transcripts. Carries forward #72978. Thanks @yeager. - Sessions: remove trajectory runtime and pointer sidecars when session maintenance prunes, caps, or disk-evicts their owning session, while preserving sidecars still referenced by live rows. Fixes #73000. Thanks @jared-rebel. diff --git a/src/gateway/protocol/schema/primitives.ts b/src/gateway/protocol/schema/primitives.ts index 47e5a0dabd7..0e3534d8e52 100644 --- a/src/gateway/protocol/schema/primitives.ts +++ b/src/gateway/protocol/schema/primitives.ts @@ -2,8 +2,10 @@ import { Type } from "typebox"; import { ENV_SECRET_REF_ID_RE } from "../../../config/types.secrets.js"; import { EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN, - FILE_SECRET_REF_ID_PATTERN, + FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN, + FILE_SECRET_REF_ID_INVALID_ESCAPE_JSON_SCHEMA_PATTERN, SECRET_PROVIDER_ALIAS_PATTERN, + SINGLE_VALUE_FILE_REF_ID, } from "../../../secrets/ref-contract.js"; import { INPUT_PROVENANCE_KIND_VALUES } from "../../../sessions/input-provenance.js"; import { SESSION_LABEL_MAX_LENGTH } from "../../../sessions/session-label.js"; @@ -53,11 +55,24 @@ const EnvSecretRefSchema = Type.Object( { additionalProperties: false }, ); +const FileSecretRefIdSchema = Type.Unsafe({ + type: "string", + anyOf: [ + { const: SINGLE_VALUE_FILE_REF_ID }, + { + allOf: [ + { pattern: FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN }, + { not: { pattern: FILE_SECRET_REF_ID_INVALID_ESCAPE_JSON_SCHEMA_PATTERN } }, + ], + }, + ], +}); + const FileSecretRefSchema = Type.Object( { source: Type.Literal("file"), provider: SecretProviderAliasString, - id: Type.String({ pattern: FILE_SECRET_REF_ID_PATTERN.source }), + id: FileSecretRefIdSchema, }, { additionalProperties: false }, ); diff --git a/src/secrets/exec-secret-ref-id-parity.test.ts b/src/secrets/exec-secret-ref-id-parity.test.ts index 44cb3ab48ac..524db12c041 100644 --- a/src/secrets/exec-secret-ref-id-parity.test.ts +++ b/src/secrets/exec-secret-ref-id-parity.test.ts @@ -4,7 +4,9 @@ import { validateConfigObjectRaw } from "../config/validation.js"; import { SecretRefSchema as GatewaySecretRefSchema } from "../gateway/protocol/schema/primitives.js"; import { buildSecretInputSchema } from "../plugin-sdk/secret-input-schema.js"; import { + INVALID_FILE_SECRET_REF_IDS, INVALID_EXEC_SECRET_REF_IDS, + VALID_FILE_SECRET_REF_IDS, VALID_EXEC_SECRET_REF_IDS, } from "../test-utils/secret-ref-test-vectors.js"; import { @@ -13,7 +15,7 @@ import { TALK_TEST_PROVIDER_ID, } from "../test-utils/talk-test-provider.js"; import { isSecretsApplyPlan } from "./plan.js"; -import { isValidExecSecretRefId } from "./ref-contract.js"; +import { isValidExecSecretRefId, isValidFileSecretRefId } from "./ref-contract.js"; import { materializePathTokens, parsePathPattern } from "./target-registry-pattern.js"; import { canonicalizeSecretTargetCoverageId } from "./target-registry-test-helpers.js"; import { listSecretTargetRegistryEntries } from "./target-registry.js"; @@ -39,6 +41,21 @@ describe("exec SecretRef id parity", () => { return result.ok; } + function configAcceptsFileRef(id: string): boolean { + const result = validateConfigObjectRaw({ + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: { source: "file", provider: "default", id }, + models: [{ id: "gpt-5", name: "gpt-5" }], + }, + }, + }, + }); + return result.ok; + } + function planAcceptsExecRef(id: string): boolean { return isSecretsApplyPlan({ version: 1, @@ -57,6 +74,17 @@ describe("exec SecretRef id parity", () => { }); } + for (const id of [...VALID_FILE_SECRET_REF_IDS, ...INVALID_FILE_SECRET_REF_IDS]) { + it(`keeps config/gateway/plugin parity for file id "${id}"`, () => { + const expected = isValidFileSecretRefId(id); + expect(configAcceptsFileRef(id)).toBe(expected); + expect(validateGatewaySecretRef({ source: "file", provider: "default", id })).toBe(expected); + expect( + pluginSdkSecretInput.safeParse({ source: "file", provider: "default", id }).success, + ).toBe(expected); + }); + } + for (const id of [...VALID_EXEC_SECRET_REF_IDS, ...INVALID_EXEC_SECRET_REF_IDS]) { it(`keeps config/plan/gateway/plugin parity for exec id "${id}"`, () => { const expected = isValidExecSecretRefId(id); diff --git a/src/secrets/ref-contract.test.ts b/src/secrets/ref-contract.test.ts index 2820ee71f46..3940b7577d7 100644 --- a/src/secrets/ref-contract.test.ts +++ b/src/secrets/ref-contract.test.ts @@ -1,9 +1,29 @@ import { describe, expect, it } from "vitest"; import { + INVALID_FILE_SECRET_REF_IDS, INVALID_EXEC_SECRET_REF_IDS, + VALID_FILE_SECRET_REF_IDS, VALID_EXEC_SECRET_REF_IDS, } from "../test-utils/secret-ref-test-vectors.js"; -import { isValidExecSecretRefId, validateExecSecretRefId } from "./ref-contract.js"; +import { + isValidExecSecretRefId, + isValidFileSecretRefId, + validateExecSecretRefId, +} from "./ref-contract.js"; + +describe("file secret ref id validation", () => { + it("accepts valid file secret ref ids", () => { + for (const id of VALID_FILE_SECRET_REF_IDS) { + expect(isValidFileSecretRefId(id), `expected valid id: ${id}`).toBe(true); + } + }); + + it("rejects invalid file secret ref ids", () => { + for (const id of INVALID_FILE_SECRET_REF_IDS) { + expect(isValidFileSecretRefId(id), `expected invalid id: ${id}`).toBe(false); + } + }); +}); describe("exec secret ref id validation", () => { it("accepts valid exec secret ref ids", () => { diff --git a/src/secrets/ref-contract.ts b/src/secrets/ref-contract.ts index 946e9ca1bb3..acf1b8d53f0 100644 --- a/src/secrets/ref-contract.ts +++ b/src/secrets/ref-contract.ts @@ -9,7 +9,8 @@ export const SECRET_PROVIDER_ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,63}$/; const EXEC_SECRET_REF_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/; export const SINGLE_VALUE_FILE_REF_ID = "value"; -export const FILE_SECRET_REF_ID_PATTERN = /^(?:value|\/(?:[^~]|~0|~1)*(?:\/(?:[^~]|~0|~1)*)*)$/; +export const FILE_SECRET_REF_ID_ABSOLUTE_JSON_SCHEMA_PATTERN = "^/"; +export const FILE_SECRET_REF_ID_INVALID_ESCAPE_JSON_SCHEMA_PATTERN = "~(?:[^01]|$)"; export const EXEC_SECRET_REF_ID_JSON_SCHEMA_PATTERN = "^(?!.*(?:^|/)\\.{1,2}(?:/|$))[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$"; diff --git a/src/test-utils/secret-ref-test-vectors.ts b/src/test-utils/secret-ref-test-vectors.ts index 7645f4c24f2..fadcb13c6ab 100644 --- a/src/test-utils/secret-ref-test-vectors.ts +++ b/src/test-utils/secret-ref-test-vectors.ts @@ -1,3 +1,22 @@ +export const VALID_FILE_SECRET_REF_IDS = [ + "value", + "/", + "//", + "/providers/openai/apiKey", + "/providers//apiKey", + "/~0/~1", + `//${"/".repeat(256)}`, +] as const; + +export const INVALID_FILE_SECRET_REF_IDS = [ + "", + "providers/openai/apiKey", + "value/extra", + "/providers/openai/apiKey~", + "/providers/openai/apiKey~2", + "/providers/openai/~", +] as const; + export const VALID_EXEC_SECRET_REF_IDS = [ "vault/openai/api-key", "vault:secret/mykey",