feat(config): validate clients and scope security
This commit is contained in:
+38
-7
@@ -12,7 +12,7 @@ $pluginEntries = @(Import-Csv -LiteralPath $pluginManifest -Delimiter ([char]9))
|
||||
if ($pluginEntries.Count -eq 0) { throw 'Plugin manifest must define at least one plugin.' }
|
||||
$pluginNames = @()
|
||||
foreach ($pluginEntry in $pluginEntries) {
|
||||
foreach ($field in @('name','claude_plugin','codex_plugin')) {
|
||||
foreach ($field in @('name','claude_plugin','codex_method')) {
|
||||
if ([string]::IsNullOrWhiteSpace($pluginEntry.$field)) { throw "Missing $field for plugin $($pluginEntry.name)." }
|
||||
}
|
||||
if ($pluginNames -contains $pluginEntry.name) { throw "Duplicate plugin name in manifest: $($pluginEntry.name)" }
|
||||
@@ -52,6 +52,32 @@ function Read-Field($path, $name) {
|
||||
function Quote-Toml($value) {
|
||||
return ('"' + $value.Replace('\','\\').Replace('"','\"').Replace("`r",'').Replace("`n",'\n') + '"')
|
||||
}
|
||||
function Test-CodexSchema($configPath) {
|
||||
if (-not (Get-Command codex -ErrorAction SilentlyContinue)) {
|
||||
if ($env:REQUIRE_CODEX_SCHEMA -eq 'true') { throw 'Codex CLI is required for schema validation.' }
|
||||
Write-Warning 'Codex CLI unavailable; skipped Codex schema validation.'
|
||||
return
|
||||
}
|
||||
$tempHome = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-schema-" + [Guid]::NewGuid())
|
||||
$previousCodexHome = $env:CODEX_HOME
|
||||
try {
|
||||
New-Item $tempHome -ItemType Directory -Force | Out-Null
|
||||
Copy-Item $configPath (Join-Path $tempHome 'config.toml') -Force
|
||||
$env:CODEX_HOME = $tempHome
|
||||
$previousErrorActionPreference = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$schemaOutput = @(& codex --strict-config --help 2>&1)
|
||||
$schemaExitCode = $LASTEXITCODE
|
||||
} finally {
|
||||
$ErrorActionPreference = $previousErrorActionPreference
|
||||
}
|
||||
if ($schemaExitCode -ne 0) { throw "Codex schema validation failed: $configPath`n$($schemaOutput -join [Environment]::NewLine)" }
|
||||
} finally {
|
||||
$env:CODEX_HOME = $previousCodexHome
|
||||
Remove-Item $tempHome -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
& (Join-Path $root 'shared/hooks/scripts/Test-SessionConfig.ps1') -RepositoryRoot $root
|
||||
|
||||
@@ -75,6 +101,14 @@ Detect the languages, frameworks, tools, and change areas from the repository an
|
||||
|
||||
Load rule files when their subject applies:
|
||||
|
||||
- rules/security-auth.md for authentication, authorization, sessions, tokens, permissions, or tenant boundaries.
|
||||
- rules/security-web.md for browser, frontend, cookie, redirect, XSS, or CSRF work.
|
||||
- rules/security-api.md for HTTP APIs, request handling, serialization, or endpoints.
|
||||
- rules/security-data.md for databases, persistence, sensitive data, or multi-tenancy.
|
||||
- rules/security-files.md for files, uploads, archives, paths, processes, IPC, or deserialization.
|
||||
- rules/security-network.md for network access, URLs, TLS, proxies, or SSRF.
|
||||
- rules/security-crypto.md for cryptography, secrets, credentials, keys, or tokens.
|
||||
- rules/security-supply-chain.md for dependencies, packages, plugins, builds, deployments, or CI.
|
||||
- rules/angular.md for Angular work.
|
||||
- rules/typescript.md for TypeScript work.
|
||||
- rules/csharp.md for C# or .NET work.
|
||||
@@ -154,13 +188,10 @@ foreach ($platform in @('windows','linux')) {
|
||||
}
|
||||
|
||||
$tomlPath = Join-Path $output "codex-$platform/config.toml"
|
||||
$tomlContent = Get-Content -LiteralPath $tomlPath -Raw
|
||||
if (([regex]::Matches($tomlContent, '(?<!\\)"')).Count % 2 -ne 0) { throw "Generated Codex config.toml has unbalanced quotes: $tomlPath" }
|
||||
if (([regex]::Matches($tomlContent, '\[')).Count -ne ([regex]::Matches($tomlContent, '\]')).Count) { throw "Generated Codex config.toml has unbalanced brackets: $tomlPath" }
|
||||
|
||||
$settingsPath = Join-Path $output "claude-$platform/settings.json"
|
||||
try { Get-Content -LiteralPath $settingsPath -Raw | ConvertFrom-Json | Out-Null }
|
||||
catch { throw "Generated Claude settings.json is not valid JSON: $settingsPath" }
|
||||
& python (Join-Path $root 'scripts/validate-config.py') $tomlPath $settingsPath
|
||||
if ($LASTEXITCODE -ne 0) { throw "Generated configuration validation failed for $platform." }
|
||||
Test-CodexSchema $tomlPath
|
||||
}
|
||||
|
||||
$leftoverPlaceholders = @(Get-ChildItem $output -File -Recurse | ForEach-Object {
|
||||
|
||||
+19
-12
@@ -84,6 +84,14 @@ Detect the languages, frameworks, tools, and change areas from the repository an
|
||||
|
||||
Load rule files when their subject applies:
|
||||
|
||||
- rules/security-auth.md for authentication, authorization, sessions, tokens, permissions, or tenant boundaries.
|
||||
- rules/security-web.md for browser, frontend, cookie, redirect, XSS, or CSRF work.
|
||||
- rules/security-api.md for HTTP APIs, request handling, serialization, or endpoints.
|
||||
- rules/security-data.md for databases, persistence, sensitive data, or multi-tenancy.
|
||||
- rules/security-files.md for files, uploads, archives, paths, processes, IPC, or deserialization.
|
||||
- rules/security-network.md for network access, URLs, TLS, proxies, or SSRF.
|
||||
- rules/security-crypto.md for cryptography, secrets, credentials, keys, or tokens.
|
||||
- rules/security-supply-chain.md for dependencies, packages, plugins, builds, deployments, or CI.
|
||||
- rules/angular.md for Angular work.
|
||||
- rules/typescript.md for TypeScript work.
|
||||
- rules/csharp.md for C# or .NET work.
|
||||
@@ -180,19 +188,18 @@ for client in codex claude; do
|
||||
done
|
||||
|
||||
toml_path="$output/codex-$platform/config.toml"
|
||||
quote_count=$(grep -o '"' "$toml_path" | wc -l)
|
||||
[ $((quote_count % 2)) -eq 0 ] || { printf 'Generated Codex config.toml has unbalanced quotes: %s\n' "$toml_path" >&2; exit 1; }
|
||||
open_brackets=$(grep -o '\[' "$toml_path" | wc -l)
|
||||
close_brackets=$(grep -o '\]' "$toml_path" | wc -l)
|
||||
[ "$open_brackets" -eq "$close_brackets" ] || { printf 'Generated Codex config.toml has unbalanced brackets: %s\n' "$toml_path" >&2; exit 1; }
|
||||
|
||||
settings_path="$output/claude-$platform/settings.json"
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq empty "$settings_path" >/dev/null 2>&1 || { printf 'Generated Claude settings.json is not valid JSON: %s\n' "$settings_path" >&2; exit 1; }
|
||||
elif command -v python3 >/dev/null 2>&1 && python3 --version >/dev/null 2>&1; then
|
||||
# On Windows, `python3` can resolve to a non-functional Microsoft Store app-execution
|
||||
# alias that still passes `command -v`; probe it with a real invocation before trusting it.
|
||||
python3 -c 'import json,sys; json.load(open(sys.argv[1], encoding="utf-8"))' "$settings_path" || { printf 'Generated Claude settings.json is not valid JSON: %s\n' "$settings_path" >&2; exit 1; }
|
||||
python3 "$root/scripts/validate-config.py" "$toml_path" "$settings_path"
|
||||
if command -v codex >/dev/null; then
|
||||
schema_home=$(mktemp -d)
|
||||
cp "$toml_path" "$schema_home/config.toml"
|
||||
CODEX_HOME="$schema_home" codex --strict-config --help >/dev/null
|
||||
rm -rf -- "$schema_home"
|
||||
elif [ "${REQUIRE_CODEX_SCHEMA:-false}" = true ]; then
|
||||
printf 'Codex CLI is required for schema validation.\n' >&2
|
||||
exit 1
|
||||
else
|
||||
printf 'WARN Codex CLI unavailable; skipped Codex schema validation.\n' >&2
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
+16
-3
@@ -42,9 +42,16 @@ if (Test-Path -LiteralPath $settingsPath) {
|
||||
}
|
||||
$configTomlPath = Join-Path $homePath '.codex/config.toml'
|
||||
if (Test-Path -LiteralPath $configTomlPath) {
|
||||
$tomlContent = Get-Content -LiteralPath $configTomlPath -Raw
|
||||
$balanced = (([regex]::Matches($tomlContent, '(?<!\\)"')).Count % 2 -eq 0) -and (([regex]::Matches($tomlContent, '\[')).Count -eq ([regex]::Matches($tomlContent, '\]')).Count)
|
||||
if ($balanced) { Result 'PASS' 'installed .codex/config.toml looks structurally valid' } else { Result 'FAIL' 'installed .codex/config.toml has unbalanced quotes or brackets' }
|
||||
$parseResult = & python -c 'import sys,tomllib,pathlib; tomllib.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8-sig"))' $configTomlPath 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { Result 'PASS' 'installed .codex/config.toml parses as TOML' } else { Result 'FAIL' "installed .codex/config.toml is invalid TOML: $parseResult" }
|
||||
if (Get-Command codex -ErrorAction SilentlyContinue) {
|
||||
$previousCodexHome = $env:CODEX_HOME
|
||||
try {
|
||||
$env:CODEX_HOME = Split-Path $configTomlPath -Parent
|
||||
$schemaResult = & codex --strict-config --help 2>&1
|
||||
if ($LASTEXITCODE -eq 0) { Result 'PASS' 'installed .codex/config.toml matches the installed Codex schema' } else { Result 'FAIL' "installed .codex/config.toml has unsupported Codex settings: $schemaResult" }
|
||||
} finally { $env:CODEX_HOME = $previousCodexHome }
|
||||
} else { Result 'WARN' 'Codex CLI unavailable; skipped installed Codex schema validation' }
|
||||
}
|
||||
|
||||
foreach ($destination in @((Join-Path $homePath '.codex'), (Join-Path $homePath '.agents/skills'), (Join-Path $homePath '.claude'))) {
|
||||
@@ -64,6 +71,12 @@ foreach ($destination in @((Join-Path $homePath '.codex'), (Join-Path $homePath
|
||||
|
||||
foreach ($path in @('shared/rules','shared/skills','shared/agents','shared/hooks/scripts')) { if (Test-Path (Join-Path $root $path)) { Result 'PASS' "source $path" } else { Result 'FAIL' "missing $path" } }
|
||||
|
||||
foreach ($client in @('codex','claude')) {
|
||||
$config = if ($client -eq 'codex') { Join-Path $root 'adapters/codex/config/config.toml' } else { Join-Path $root 'adapters/claude/config/settings.json' }
|
||||
$registered = if ($client -eq 'codex') { (Get-Content $config -Raw) -match '\[\[hooks\.Stop\]\]' } else { ((Get-Content $config -Raw | ConvertFrom-Json).hooks.PSObject.Properties.Name -contains 'Stop') }
|
||||
if ($registered) { Result 'PASS' "$client Stop hook is registered" } else { Result 'WARN' "$client ships hook utilities, but no Stop hook is registered" }
|
||||
}
|
||||
|
||||
if ($fail) { exit 1 }
|
||||
Write-Output 'PASS doctor'
|
||||
exit 0
|
||||
|
||||
+6
-6
@@ -55,14 +55,14 @@ if [ -f "$home_path/.claude/settings.json" ]; then
|
||||
fi
|
||||
fi
|
||||
if [ -f "$home_path/.codex/config.toml" ]; then
|
||||
quote_count=$(grep -o '"' "$home_path/.codex/config.toml" | wc -l)
|
||||
open_brackets=$(grep -o '\[' "$home_path/.codex/config.toml" | wc -l)
|
||||
close_brackets=$(grep -o '\]' "$home_path/.codex/config.toml" | wc -l)
|
||||
if [ $((quote_count % 2)) -eq 0 ] && [ "$open_brackets" -eq "$close_brackets" ]; then
|
||||
result PASS 'installed .codex/config.toml looks structurally valid'
|
||||
if python3 -c 'import sys,tomllib,pathlib; tomllib.loads(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8-sig"))' "$home_path/.codex/config.toml"; then
|
||||
result PASS 'installed .codex/config.toml parses as TOML'
|
||||
else
|
||||
result FAIL 'installed .codex/config.toml has unbalanced quotes or brackets'
|
||||
result FAIL 'installed .codex/config.toml is invalid TOML'
|
||||
fi
|
||||
if command -v codex >/dev/null; then
|
||||
if CODEX_HOME="$home_path/.codex" codex --strict-config --help >/dev/null 2>&1; then result PASS 'installed .codex/config.toml matches the installed Codex schema'; else result FAIL 'installed .codex/config.toml has unsupported Codex settings'; fi
|
||||
else result WARN 'Codex CLI unavailable; skipped installed Codex schema validation'; fi
|
||||
fi
|
||||
|
||||
for destination in "$home_path/.codex" "$home_path/.agents/skills" "$home_path/.claude"; do
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate the generated configuration formats without third-party packages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CLAUDE_TOP_LEVEL = {"permissions", "env", "statusLine", "hooks"}
|
||||
CLAUDE_PERMISSION_KEYS = {"defaultMode", "allow", "deny"}
|
||||
CLAUDE_STATUS_LINE_KEYS = {"type", "command"}
|
||||
CLAUDE_HOOK_KEYS = {"type", "command", "timeout"}
|
||||
CLAUDE_PERMISSION_MODES = {"acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"}
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def require_keys(value: object, allowed: set[str], label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
fail(f"{label} must be an object")
|
||||
unknown = set(value) - allowed
|
||||
if unknown:
|
||||
fail(f"{label} has unknown keys: {', '.join(sorted(unknown))}")
|
||||
return value
|
||||
|
||||
|
||||
def validate_claude(path: Path) -> None:
|
||||
settings = require_keys(json.loads(path.read_text(encoding="utf-8-sig")), CLAUDE_TOP_LEVEL, str(path))
|
||||
permissions = require_keys(settings.get("permissions"), CLAUDE_PERMISSION_KEYS, "permissions")
|
||||
if permissions.get("defaultMode") not in CLAUDE_PERMISSION_MODES:
|
||||
fail("permissions.defaultMode is unsupported")
|
||||
for key in ("allow", "deny"):
|
||||
if not isinstance(permissions.get(key), list) or not all(isinstance(item, str) for item in permissions[key]):
|
||||
fail(f"permissions.{key} must be an array of strings")
|
||||
env = settings.get("env")
|
||||
if not isinstance(env, dict) or not all(isinstance(key, str) and isinstance(value, str) for key, value in env.items()):
|
||||
fail("env must be an object of string values")
|
||||
status_line = require_keys(settings.get("statusLine"), CLAUDE_STATUS_LINE_KEYS, "statusLine")
|
||||
if status_line.get("type") != "command" or not isinstance(status_line.get("command"), str):
|
||||
fail("statusLine must be a command")
|
||||
hooks = settings.get("hooks")
|
||||
if not isinstance(hooks, dict):
|
||||
fail("hooks must be an object")
|
||||
for event, matchers in hooks.items():
|
||||
if not isinstance(event, str) or not isinstance(matchers, list):
|
||||
fail("hooks must map event names to arrays")
|
||||
for matcher in matchers:
|
||||
if not isinstance(matcher, dict) or not isinstance(matcher.get("hooks"), list):
|
||||
fail(f"hooks.{event} must contain hook arrays")
|
||||
for hook in matcher["hooks"]:
|
||||
hook = require_keys(hook, CLAUDE_HOOK_KEYS, f"hooks.{event}")
|
||||
if hook.get("type") != "command" or not isinstance(hook.get("command"), str) or not isinstance(hook.get("timeout"), int):
|
||||
fail(f"hooks.{event} contains an unsupported command hook")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 3:
|
||||
print("usage: validate-config.py <codex-toml> <claude-json>", file=sys.stderr)
|
||||
return 2
|
||||
tomllib.loads(Path(sys.argv[1]).read_text(encoding="utf-8-sig"))
|
||||
validate_claude(Path(sys.argv[2]))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except (OSError, ValueError, json.JSONDecodeError, tomllib.TOMLDecodeError) as error:
|
||||
print(f"configuration validation failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
Reference in New Issue
Block a user