Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Support wildcard memory schema targets
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Oct 10, 2026
commit c52e1cdaa9d35994c2899c960b727362d534e4d6
2 changes: 1 addition & 1 deletion .github/aw/memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ tools:
if (memoryKind !== "cache") throw new Error("unexpected memory kind");
```

Each declaration has an exact relative `file`, explicit `format` (`json` or `jsonl`), and inline schema object. A declared file must match configured file filters and exist. JSON is one non-empty document; JSONL validates records individually, accepts LF/CRLF and an optional final newline, allows an existing empty file, and rejects blank lines or malformed records with physical line numbers. Schemas validate without coercion or rewriting and run after filtering/normalization, before an optional `validation.script`; schema and script failures both reject persistence.
Each declaration has a relative `file` path or glob (using `*` within a path segment and `**` across segments), explicit `format` (`json` or `jsonl`), and inline schema object. A path must match configured file filters and exist; a glob must match at least one file, and every match is validated. JSON is one non-empty document; JSONL validates records individually, accepts LF/CRLF and an optional final newline, allows an existing empty file, and rejects blank lines or malformed records with physical line numbers. Schemas validate without coercion or rewriting and run after filtering/normalization, before an optional `validation.script`; schema and script failures both reject persistence.

Supported schema keywords are `type` (including arrays of types and `null`), primitive `enum`, `required`, nested `properties`, `additionalProperties: false`, object-schema `items`, and standalone `oneOf`/`anyOf`, up to 32 levels. Numeric enum integers must be exactly representable by JavaScript, and values checked against numeric enums must not lose precision during JSON parsing. This is not full JSON Schema: `$ref`, `const`, `format`, patterns, bounds, `uniqueItems`, and other unsupported keywords are rejected. Use the optional Node.js script (globals: `fs`, `path`, `memoryRoot`, `memoryId`, `memoryKind`) for cross-file uniqueness, timestamps, positive-ID checks, and other domain rules. Its timeout defaults to 1 minute (`validation.timeout-minutes`, max 5). See [cache-memory reference](https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/cache-memory.md#custom-validation), [repo-memory reference](https://github.com/github/gh-aw/blob/main/docs/src/content/docs/reference/repo-memory.md#custom-validation), and [drive-memory reference](https://github.com/github/gh-aw/blob/main/docs/src/content/docs/experimental/drive-memory.md#validation).
### Storage path
Expand Down
187 changes: 129 additions & 58 deletions actions/setup/js/memory_custom_validation.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -238,17 +238,74 @@ function validateSchemaFilePath(file) {
typeof file !== "string" ||
file === "" ||
file.includes("\\") ||
/[*?[\]]/.test(file) ||
/[?[\]]/.test(file) ||
path.posix.isAbsolute(file) ||
path.win32.isAbsolute(file) ||
/^[A-Za-z]:/.test(file) ||
path.posix.normalize(file) !== file ||
file.split("/").some(segment => segment === "" || segment === "." || segment === "..")
) {
throw new TypeError("file must be a non-empty relative path without traversal or glob characters");
throw new TypeError("file must be a non-empty relative path or glob without traversal");
}
}

/**
* @param {string} file
* @param {string} pattern
*/
function memorySchemaFileMatchesGlob(file, pattern) {
let expression = "^";
for (let index = 0; index < pattern.length;) {
if (pattern.startsWith("**/", index)) {
expression += "(?:.*/)?";
index += 3;
} else if (pattern.startsWith("**", index)) {
expression += ".*";
index += 2;
} else if (pattern[index] === "*") {
expression += "[^/]*";
index++;
} else {
expression += pattern[index].replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
index++;
}
}
return new RegExp(expression + "$").test(file);
}

/**
* @param {string} memoryDir
* @param {string} pattern
* @returns {string[]}
*/
function findMemorySchemaFiles(memoryDir, pattern) {
/** @type {string[]} */
const matches = [];
/**
* @param {string} directory
* @param {string} relativeDirectory
*/
function visit(directory, relativeDirectory) {
let entries;
try {
entries = fs.readdirSync(directory, { withFileTypes: true });
} catch (error) {
throw new Error(`Unable to search for schema files in '${relativeDirectory || "."}': ${getErrorMessage(error)}`, { cause: error });
}
for (const entry of entries) {
const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
visit(fullPath, relativePath);
} else if ((entry.isFile() || entry.isSymbolicLink()) && memorySchemaFileMatchesGlob(relativePath, pattern)) {
matches.push(relativePath);
}
}
}
visit(memoryDir, "");
return matches.sort();
}

/**
* @param {Record<string, any>} schema
* @returns {boolean}
Expand Down Expand Up @@ -416,70 +473,84 @@ function validateMemoryJSONSchemas(memoryDir, schemas, kind, memoryId) {
throw new TypeError(`file '${declaration.file}' has unsupported format`);
}
validateSchemaContract(declaration.schema, "Memory");

const relativeFile = declaration.file.split("/").join(path.sep);
const lexicalPath = path.resolve(memoryDir, relativeFile);
const lexicalRelative = path.relative(path.resolve(memoryDir), lexicalPath);
if (lexicalRelative === ".." || lexicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(lexicalRelative)) {
throw new TypeError(`file '${declaration.file}' escapes the memory directory`);
}
let root;
let resolvedFile;
try {
root = fs.realpathSync(memoryDir);
resolvedFile = fs.realpathSync(lexicalPath);
} catch (error) {
throw new Error(`file '${declaration.file}' is missing or unreadable: ${getErrorMessage(error)}`, { cause: error });
const matchedFiles = declaration.file.includes("*") ? findMemorySchemaFiles(memoryDir, declaration.file) : [declaration.file];
if (matchedFiles.length === 0) {
throw new Error(`file pattern '${declaration.file}' matched no files`);
}
const relativeResolved = path.relative(root, resolvedFile);
if (relativeResolved === ".." || relativeResolved.startsWith(`..${path.sep}`) || path.isAbsolute(relativeResolved)) {
throw new Error(`file '${declaration.file}' resolves outside the memory directory`);
for (const file of matchedFiles) {
validateMemoryJSONSchemaFile(memoryDir, file, declaration);
}
let stats;
try {
stats = fs.statSync(resolvedFile);
} catch (error) {
throw new Error(`file '${declaration.file}' is unreadable: ${getErrorMessage(error)}`, { cause: error });
}
if (!stats.isFile()) {
throw new Error(`file '${declaration.file}' must be a regular file`);
}
if (stats.size > MAX_SCHEMA_FILE_BYTES) {
throw new Error(`file '${declaration.file}' exceeds the schema validation size limit`);
}
if (declaration.format === "jsonl") {
validateJSONLFile(resolvedFile, declaration.file, declaration.schema);
continue;
}
return `Declarative ${kind}-memory schemas passed for '${memoryId}'`;
}

/**
* @param {string} memoryDir
* @param {string} file
* @param {{file: string, format: string, schema: Record<string, any>}} declaration
*/
function validateMemoryJSONSchemaFile(memoryDir, file, declaration) {
const relativeFile = file.split("/").join(path.sep);
const lexicalPath = path.resolve(memoryDir, relativeFile);
const lexicalRelative = path.relative(path.resolve(memoryDir), lexicalPath);
if (lexicalRelative === ".." || lexicalRelative.startsWith(`..${path.sep}`) || path.isAbsolute(lexicalRelative)) {
throw new TypeError(`file '${file}' escapes the memory directory`);
}
let root;
let resolvedFile;
try {
root = fs.realpathSync(memoryDir);
resolvedFile = fs.realpathSync(lexicalPath);
} catch (error) {
throw new Error(`file '${file}' is missing or unreadable: ${getErrorMessage(error)}`, { cause: error });
}
const relativeResolved = path.relative(root, resolvedFile);
if (relativeResolved === ".." || relativeResolved.startsWith(`..${path.sep}`) || path.isAbsolute(relativeResolved)) {
throw new Error(`file '${file}' resolves outside the memory directory`);
}
let stats;
try {
stats = fs.statSync(resolvedFile);
} catch (error) {
throw new Error(`file '${file}' is unreadable: ${getErrorMessage(error)}`, { cause: error });
}
if (!stats.isFile()) {
throw new Error(`file '${file}' must be a regular file`);
}
if (stats.size > MAX_SCHEMA_FILE_BYTES) {
throw new Error(`file '${file}' exceeds the schema validation size limit`);
}
if (declaration.format === "jsonl") {
validateJSONLFile(resolvedFile, file, declaration.schema);
return;
}
let contents;
try {
contents = fs.readFileSync(resolvedFile, "utf8");
} catch (error) {
throw new Error(`file '${file}' is unreadable: ${getErrorMessage(error)}`, { cause: error });
}
if (declaration.format === "json") {
if (!contents.trim()) throw new Error(`file '${file}' contains empty JSON`);
if (schemaContainsNumericEnum(declaration.schema)) {
try {
assertJSONNumbersExactlyRepresentable(contents);
} catch (error) {
throw new Error(`file '${file}' ${getErrorMessage(error)}`, { cause: error });
}
}
let contents;
let value;
try {
contents = fs.readFileSync(resolvedFile, "utf8");
value = JSON.parse(contents);
} catch (error) {
throw new Error(`file '${declaration.file}' is unreadable: ${getErrorMessage(error)}`, { cause: error });
throw new Error(`file '${file}' contains malformed JSON: ${getErrorMessage(error)}`, { cause: error });
}
if (declaration.format === "json") {
if (!contents.trim()) throw new Error(`file '${declaration.file}' contains empty JSON`);
if (schemaContainsNumericEnum(declaration.schema)) {
try {
assertJSONNumbersExactlyRepresentable(contents);
} catch (error) {
throw new Error(`file '${declaration.file}' ${getErrorMessage(error)}`, { cause: error });
}
}
let value;
try {
value = JSON.parse(contents);
} catch (error) {
throw new Error(`file '${declaration.file}' contains malformed JSON: ${getErrorMessage(error)}`, { cause: error });
}
const validationError = validateValueAgainstSchema(value, declaration.schema);
if (validationError) {
throw new Error(`file '${declaration.file}' at ${validationError.path || "(root)"} ${validationError.message}`);
}
continue;
const validationError = validateValueAgainstSchema(value, declaration.schema);
if (validationError) {
throw new Error(`file '${file}' at ${validationError.path || "(root)"} ${validationError.message}`);
}
return;
}
return `Declarative ${kind}-memory schemas passed for '${memoryId}'`;
}

/**
Expand Down
40 changes: 40 additions & 0 deletions actions/setup/js/memory_custom_validation.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,38 @@ describe("memory_custom_validation", () => {
expect(fs.readFileSync(file, "utf8")).toBe(contents);
});

it("validates every file matched by a recursive schema glob", () => {
fs.mkdirSync(path.join(tempDir, "events", "nested"), { recursive: true });
fs.writeFileSync(path.join(tempDir, "events", "first.json"), '{"ok":true}');
fs.writeFileSync(path.join(tempDir, "events", "nested", "second.json"), '{"ok":true}');
const jsonSchemas = [
{
file: "events/**/*.json",
format: "json",
schema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } },
},
];
expect(runCustomMemoryValidation({ jsonSchemas, memoryDir: tempDir, kind: "repo" }).ok).toBe(true);

fs.writeFileSync(path.join(tempDir, "events", "nested", "second.json"), '{"ok":"no"}');
const result = runCustomMemoryValidation({ jsonSchemas, memoryDir: tempDir, kind: "repo" });
expect(result.ok).toBe(false);
expect(result.stderr).toContain("events/nested/second.json");
expect(result.stderr).toContain("ok");
});

it("fails when a schema glob matches no files", () => {
const result = runCustomMemoryValidation({
jsonSchemas: [{ file: "events/**/*.json", format: "json", schema: { type: "object" } }],
memoryDir: tempDir,
kind: "repo",
});

expect(result.ok).toBe(false);
expect(result.stderr).toContain("events/**/*.json");
expect(result.stderr).toContain("matched no files");
});

it("reports nested schema paths and checks schemas before custom scripts", () => {
fs.writeFileSync(path.join(tempDir, "state.json"), '{"items":[1]}');
const result = runCustomMemoryValidation({
Expand Down Expand Up @@ -223,6 +255,14 @@ describe("memory_custom_validation", () => {
expect(result.ok).toBe(false);
expect(result.stderr).toContain(file);
}
const wildcardResult = runCustomMemoryValidation({
jsonSchemas: [{ file: "external-*.json", format: "json", schema: { type: "object" } }],
memoryDir: tempDir,
kind: "repo",
});
expect(wildcardResult.ok).toBe(false);
expect(wildcardResult.stderr).toContain("external-link.json");
expect(wildcardResult.stderr).toContain("outside the memory directory");
} finally {
fs.rmSync(outsideDir, { recursive: true, force: true });
}
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/experimental/drive-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Drive names are repository-wide and branch-aware according to the preview servic

## Validation

Drive-memory supports the same `validation.json-schemas` declarations as repo-memory and cache-memory. Declare exact relative file paths with `format: json` or `format: jsonl` and an inline `schema`; each declared file is required and must pass validation before the drive accepts or persists the candidate.
Drive-memory supports the same `validation.json-schemas` declarations as repo-memory and cache-memory. Declare relative file paths or globs (with `*` within a path segment and `**` across segments) with `format: json` or `format: jsonl` and an inline `schema`; each path must exist, each glob must match at least one file, and every matched file must pass validation before the drive accepts or persists the candidate.

JSON requires one non-empty document. JSONL validates each physical record independently, accepts LF/CRLF and an optional final newline, permits an existing empty file, and rejects blank or malformed records with a line number. Schema validation runs after filtering and configured normalization, before an optional `validation.script`, and gates drive persistence. The supported vocabulary is `type`, primitive `enum`, `required`, nested `properties`, `additionalProperties: false`, `items`, and standalone `oneOf`/`anyOf` (maximum depth 32); this is not full JSON Schema. Numeric enum integers must be exactly representable by JavaScript, and values checked against numeric enums must not lose precision during JSON parsing. Unsupported keywords are rejected at compile time. A script remains useful for cross-file or domain-specific rules. See [Cache Memory](../reference/cache-memory/) for a complete example and details.

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/cache-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ When a cache is restored for agent execution, gh-aw also strips execute bits fro

### Custom validation

Use `validation.json-schemas` for required per-file JSON/JSONL structure checks. Every declared exact relative path must match the configured `allowed-extensions` policy and exist in the candidate; missing files fail validation. JSON must contain one non-empty document. JSONL validates each record independently, accepts LF/CRLF and an optional final newline, permits an existing empty file, and rejects blank physical lines or malformed records with a line number. Validation does not rewrite data. Schemas run after filtering and configured normalization, before any optional `validation.script`, and before cache save; when threat detection is enabled, they also run in the `update_cache_memory` job before `actions/cache/save`.
Use `validation.json-schemas` for required per-file JSON/JSONL structure checks. Each `file` may be a relative path or glob (`*` matches within a path segment; `**` spans segments). Paths must match the configured `allowed-extensions` policy and exist in the candidate; globs must match at least one file, and every match is validated. JSON must contain one non-empty document. JSONL validates each record independently, accepts LF/CRLF and an optional final newline, permits an existing empty file, and rejects blank physical lines or malformed records with a line number. Validation does not rewrite data. Schemas run after filtering and configured normalization, before any optional `validation.script`, and before cache save; when threat detection is enabled, they also run in the `update_cache_memory` job before `actions/cache/save`.

The supported vocabulary is limited to `type` (including type lists and `null`), primitive `enum`, `required`, nested `properties`, `additionalProperties: false`, object-schema `items`, and standalone `oneOf`/`anyOf`, with a maximum nesting depth of 32 and no sibling constraints beside alternatives. Numeric enum integers must be exactly representable by JavaScript, and values checked against numeric enums must not lose precision during JSON parsing. This is not full JSON Schema: `$ref`, `const`, schema `format`, `pattern`, numeric/string/array bounds, and `uniqueItems` are rejected. Use `validation.script` for domain-specific constraints such as cross-file uniqueness, positive-ID checks, or timestamp policies.

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/repo-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ On a shared memory branch, `file-glob` and `allowed-extensions` scope normalizat

### Custom validation

Use `validation.json-schemas` for required per-file structural checks. Every declared exact relative path must match the configured `file-glob` and `allowed-extensions` policies and exist in the candidate; missing files fail validation. A `json` file must contain one non-empty valid JSON document. A `jsonl` file is checked record by record: LF and CRLF are accepted, a final newline is optional, an existing empty file has zero records, and blank physical lines or malformed records fail with a line number. Schema checks do not coerce, default, strip, or rewrite values. They run after configured filtering and `format-json` normalization, before an optional `validation.script`, and are repeated before upload/commit and in the repo-memory push job.
Use `validation.json-schemas` for required per-file structural checks. Each `file` may be a relative path or glob (`*` matches within a path segment; `**` spans segments). Paths must match the configured `file-glob` and `allowed-extensions` policies and exist in the candidate; globs must match at least one file, and every match is validated. A `json` file must contain one non-empty valid JSON document. A `jsonl` file is checked record by record: LF and CRLF are accepted, a final newline is optional, an existing empty file has zero records, and blank physical lines or malformed records fail with a line number. Schema checks do not coerce, default, strip, or rewrite values. They run after configured filtering and `format-json` normalization, before an optional `validation.script`, and are repeated before upload/commit and in the repo-memory push job.

The supported schema vocabulary is deliberately limited to `type` (including type lists and `null`), primitive `enum`, `required`, nested `properties`, `additionalProperties: false`, object-schema `items`, and standalone `oneOf` or `anyOf`. Schemas are limited to 32 levels; alternatives cannot have sibling constraints. Numeric enum integers must be exactly representable by JavaScript, and values checked against numeric enums must not lose precision during JSON parsing. This is not full JSON Schema support: keywords such as `$ref`, `const`, `format`, `pattern`, numeric/string/array bounds, and `uniqueItems` are rejected at compile time. Use a script for cross-file rules, uniqueness, positive-ID checks, timestamps, or other unsupported constraints.

Expand Down
Loading
Loading