Skip to content

Commit 2ee3b80

Browse files
committed
merge: bring P0-P2 hardening from feature/spz-v2-profile-core
2 parents 970e465 + afc8671 commit 2ee3b80

9 files changed

Lines changed: 241 additions & 153 deletions

File tree

cpp/CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
cmake_minimum_required(VERSION 3.16)
55

66
project(spz_gatekeeper
7-
VERSION 1.0.0
7+
VERSION 2.0.0
88
DESCRIPTION "SPZ Gatekeeper - SPZ format legality checker"
99
LANGUAGES CXX
1010
)

cpp/include/spz_gatekeeper/audit_summary.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ inline constexpr const char* kAuditModeBrowserLightweightWasmAudit =
2222
"browser_lightweight_wasm_audit";
2323
inline constexpr const char* kAuditModeLocalCliSpzArtifactAudit =
2424
"local_cli_spz_artifact_audit";
25+
inline constexpr const char* kBrowserToCliHandoffSchemaVersion =
26+
"spz_gatekeeper.browser_to_cli_handoff.v1";
2527
inline constexpr const char* kAuditBudgetColdStartMs = "cold_start_ms";
2628
inline constexpr const char* kAuditBudgetTinyCaseMs = "tiny_case_ms";
2729
inline constexpr const char* kAuditBudgetPeakMemoryMb = "peak_memory_mb";
@@ -72,10 +74,13 @@ struct CompatAuditMetrics {
7274

7375
struct BrowserAuditHandoff {
7476
std::string raw_json;
77+
std::string schema_version;
7578
std::string audit_profile;
7679
std::string audit_mode;
7780
std::string policy_mode = kAuditPolicyModeRelease;
7881
std::string verdict;
82+
std::string final_verdict;
83+
bool release_ready = false;
7984
std::string next_action;
8085
std::string bundle_id;
8186
std::string tool_version;

cpp/src/audit_summary.cc

Lines changed: 100 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -91,86 +91,6 @@ std::string TrimAsciiWhitespace(const std::string& text) {
9191
return text.substr(start, end - start);
9292
}
9393

94-
bool ExtractJsonStringField(const std::string& json_text,
95-
const std::string& field,
96-
std::string* value,
97-
std::string* err) {
98-
const std::string key = "\"" + field + "\"";
99-
const std::size_t key_pos = json_text.find(key);
100-
if (key_pos == std::string::npos) {
101-
if (err != nullptr) {
102-
*err = "missing field: " + field;
103-
}
104-
return false;
105-
}
106-
107-
const std::size_t colon_pos = json_text.find(':', key_pos + key.size());
108-
if (colon_pos == std::string::npos) {
109-
if (err != nullptr) {
110-
*err = "invalid field separator: " + field;
111-
}
112-
return false;
113-
}
114-
115-
std::size_t quote_pos = json_text.find('"', colon_pos + 1);
116-
if (quote_pos == std::string::npos) {
117-
if (err != nullptr) {
118-
*err = "missing string value: " + field;
119-
}
120-
return false;
121-
}
122-
123-
std::string parsed;
124-
bool escape = false;
125-
for (std::size_t i = quote_pos + 1; i < json_text.size(); ++i) {
126-
const char ch = json_text[i];
127-
if (escape) {
128-
switch (ch) {
129-
case '"':
130-
case '\\':
131-
case '/':
132-
parsed.push_back(ch);
133-
break;
134-
case 'b':
135-
parsed.push_back('\b');
136-
break;
137-
case 'f':
138-
parsed.push_back('\f');
139-
break;
140-
case 'n':
141-
parsed.push_back('\n');
142-
break;
143-
case 'r':
144-
parsed.push_back('\r');
145-
break;
146-
case 't':
147-
parsed.push_back('\t');
148-
break;
149-
default:
150-
parsed.push_back(ch);
151-
break;
152-
}
153-
escape = false;
154-
continue;
155-
}
156-
157-
if (ch == '\\') {
158-
escape = true;
159-
continue;
160-
}
161-
if (ch == '"') {
162-
*value = parsed;
163-
return true;
164-
}
165-
parsed.push_back(ch);
166-
}
167-
168-
if (err != nullptr) {
169-
*err = "unterminated string field: " + field;
170-
}
171-
return false;
172-
}
173-
17494

17595
std::string BuildBudgetItemJson(bool has_declared,
17696
double declared,
@@ -604,35 +524,107 @@ bool ParseBrowserAuditHandoffJson(const std::string& json_text,
604524
}
605525
return false;
606526
}
607-
if (parsed.raw_json.front() != '{' || parsed.raw_json.back() != '}') {
527+
528+
JsonParseError parse_err;
529+
const auto root = ParseJson(parsed.raw_json, &parse_err);
530+
if (!root.has_value()) {
531+
if (err != nullptr) {
532+
*err = "handoff json parse failed at offset " + std::to_string(parse_err.offset) +
533+
": " + parse_err.message;
534+
}
535+
return false;
536+
}
537+
if (root->type != JsonType::kObject) {
608538
if (err != nullptr) {
609539
*err = "handoff json must be an object";
610540
}
611541
return false;
612542
}
613543

614-
if (!ExtractJsonStringField(parsed.raw_json, "audit_profile", &parsed.audit_profile, err) ||
615-
!ExtractJsonStringField(parsed.raw_json, "audit_mode", &parsed.audit_mode, err) ||
616-
!ExtractJsonStringField(parsed.raw_json, "next_action", &parsed.next_action, err) ||
617-
!ExtractJsonStringField(parsed.raw_json, "bundle_id", &parsed.bundle_id, err) ||
618-
!ExtractJsonStringField(parsed.raw_json, "tool_version", &parsed.tool_version, err)) {
544+
const auto require_string_field = [&](std::string_view key, std::string* out) -> bool {
545+
const auto* value = root->Find(key);
546+
if (value == nullptr) {
547+
if (err != nullptr) {
548+
*err = "missing field: " + std::string(key);
549+
}
550+
return false;
551+
}
552+
if (value->type != JsonType::kString) {
553+
if (err != nullptr) {
554+
*err = "field must be string: " + std::string(key);
555+
}
556+
return false;
557+
}
558+
*out = value->string_value;
559+
return true;
560+
};
561+
562+
const auto require_bool_field = [&](std::string_view key, bool* out) -> bool {
563+
const auto* value = root->Find(key);
564+
if (value == nullptr) {
565+
if (err != nullptr) {
566+
*err = "missing field: " + std::string(key);
567+
}
568+
return false;
569+
}
570+
if (value->type != JsonType::kBool) {
571+
if (err != nullptr) {
572+
*err = "field must be bool: " + std::string(key);
573+
}
574+
return false;
575+
}
576+
*out = value->bool_value;
577+
return true;
578+
};
579+
580+
if (!require_string_field("schema_version", &parsed.schema_version) ||
581+
!require_string_field("audit_profile", &parsed.audit_profile) ||
582+
!require_string_field("audit_mode", &parsed.audit_mode) ||
583+
!require_string_field("policy_mode", &parsed.policy_mode) ||
584+
!require_string_field("next_action", &parsed.next_action) ||
585+
!require_string_field("bundle_id", &parsed.bundle_id) ||
586+
!require_string_field("tool_version", &parsed.tool_version) ||
587+
!require_string_field("final_verdict", &parsed.final_verdict) ||
588+
!require_bool_field("release_ready", &parsed.release_ready)) {
589+
return false;
590+
}
591+
592+
const auto* issues = root->Find("issues");
593+
if (issues == nullptr) {
594+
if (err != nullptr) {
595+
*err = "missing field: issues";
596+
}
619597
return false;
620598
}
621-
if (parsed.raw_json.find("\"bundle_verdict\"") != std::string::npos) {
622-
if (!ExtractJsonStringField(parsed.raw_json, "bundle_verdict", &parsed.verdict, err)) {
623-
return false;
599+
if (issues->type != JsonType::kArray) {
600+
if (err != nullptr) {
601+
*err = "field must be array: issues";
624602
}
625-
} else if (!ExtractJsonStringField(parsed.raw_json, "verdict", &parsed.verdict, err)) {
626603
return false;
627604
}
628-
if (parsed.raw_json.find("\"policy_mode\"") != std::string::npos) {
629-
std::string policy_mode;
630-
if (!ExtractJsonStringField(parsed.raw_json, "policy_mode", &policy_mode, err)) {
631-
return false;
605+
606+
const auto* bundle_verdict = root->Find("bundle_verdict");
607+
if (bundle_verdict != nullptr && bundle_verdict->type == JsonType::kString &&
608+
!bundle_verdict->string_value.empty()) {
609+
parsed.verdict = bundle_verdict->string_value;
610+
} else {
611+
const auto* verdict = root->Find("verdict");
612+
if (verdict != nullptr && verdict->type == JsonType::kString &&
613+
!verdict->string_value.empty()) {
614+
parsed.verdict = verdict->string_value;
615+
} else {
616+
parsed.verdict = parsed.final_verdict;
632617
}
633-
parsed.policy_mode = ResolvePolicyMode(policy_mode);
634618
}
635619

620+
parsed.policy_mode = ResolvePolicyMode(parsed.policy_mode);
621+
622+
if (parsed.schema_version != kBrowserToCliHandoffSchemaVersion) {
623+
if (err != nullptr) {
624+
*err = "unsupported handoff schema_version: " + parsed.schema_version;
625+
}
626+
return false;
627+
}
636628
if (parsed.audit_profile != kAuditProfileSpz) {
637629
if (err != nullptr) {
638630
*err = "unsupported handoff audit_profile: " + parsed.audit_profile;
@@ -651,6 +643,19 @@ bool ParseBrowserAuditHandoffJson(const std::string& json_text,
651643
}
652644
return false;
653645
}
646+
if (!IsSupportedAuditVerdict(parsed.final_verdict)) {
647+
if (err != nullptr) {
648+
*err = "unsupported handoff final_verdict: " + parsed.final_verdict;
649+
}
650+
return false;
651+
}
652+
const bool expected_release_ready = IsReleaseReadyVerdict(parsed.final_verdict);
653+
if (parsed.release_ready != expected_release_ready) {
654+
if (err != nullptr) {
655+
*err = "handoff release_ready must match final_verdict";
656+
}
657+
return false;
658+
}
654659
if (parsed.bundle_id.empty()) {
655660
if (err != nullptr) {
656661
*err = "handoff bundle_id is empty";

cpp/src/main.cc

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,7 @@ static std::vector<ManifestTarget> ParseStructuredManifestTargets(
10191019
}
10201020

10211021
static std::vector<ManifestTarget> ParseManifestTargets(const std::string& manifest_path,
1022+
bool allow_legacy_text_scan,
10221023
std::string* err) {
10231024
std::string text;
10241025
if (!ReadAllText(manifest_path, &text)) {
@@ -1035,6 +1036,17 @@ static std::vector<ManifestTarget> ParseManifestTargets(const std::string& manif
10351036
}
10361037
}
10371038

1039+
if (!allow_legacy_text_scan) {
1040+
if (!root.has_value()) {
1041+
*err = "manifest JSON parse failed at offset " + std::to_string(parse_err.offset) +
1042+
": " + parse_err.message +
1043+
" (legacy text-scan fallback disabled; pass --allow-legacy-text-scan to enable)";
1044+
} else {
1045+
*err = "manifest must provide structured items[] (legacy text-scan fallback disabled; pass --allow-legacy-text-scan to enable)";
1046+
}
1047+
return {};
1048+
}
1049+
10381050
return ParseManifestTargetsFromTextScan(text, manifest_path, err);
10391051
}
10401052

@@ -1255,6 +1267,7 @@ static int HandleCompatCheckCommand(int argc, char** argv) {
12551267
}
12561268

12571269
bool json = false;
1270+
bool allow_legacy_text_scan = false;
12581271
std::string single_path;
12591272
std::string dir_path;
12601273
std::string manifest_path;
@@ -1267,6 +1280,10 @@ static int HandleCompatCheckCommand(int argc, char** argv) {
12671280
json = true;
12681281
continue;
12691282
}
1283+
if (arg == "--allow-legacy-text-scan") {
1284+
allow_legacy_text_scan = true;
1285+
continue;
1286+
}
12701287
if (arg == "--dir" && i + 1 < argc) {
12711288
dir_path = argv[++i];
12721289
continue;
@@ -1303,6 +1320,10 @@ static int HandleCompatCheckCommand(int argc, char** argv) {
13031320
std::cerr << "--handoff requires single-file compat-check\n";
13041321
return 2;
13051322
}
1323+
if (allow_legacy_text_scan && manifest_path.empty()) {
1324+
std::cerr << "--allow-legacy-text-scan requires --manifest\n";
1325+
return 2;
1326+
}
13061327

13071328

13081329
std::vector<ManifestTarget> targets;
@@ -1324,7 +1345,7 @@ static int HandleCompatCheckCommand(int argc, char** argv) {
13241345
});
13251346
} else {
13261347
std::string parse_err;
1327-
targets = ParseManifestTargets(manifest_path, &parse_err);
1348+
targets = ParseManifestTargets(manifest_path, allow_legacy_text_scan, &parse_err);
13281349
if (!parse_err.empty() && targets.empty()) {
13291350
std::cerr << parse_err << "\n";
13301351
return 2;
@@ -1421,7 +1442,7 @@ static void PrintUsage() {
14211442
std::cerr << " spz_gatekeeper registry show <type> [--json]\n";
14221443
std::cerr << " spz_gatekeeper compat-check <file.spz> [--handoff <browser_audit.json>] [--json]\n";
14231444
std::cerr << " spz_gatekeeper compat-check --dir <dir> [--json]\n";
1424-
std::cerr << " spz_gatekeeper compat-check --manifest <manifest.json> [--json]\n";
1445+
std::cerr << " spz_gatekeeper compat-check --manifest <manifest.json> [--allow-legacy-text-scan] [--json]\n";
14251446

14261447

14271448
std::cerr << " spz_gatekeeper compat-board [--json]\n";
@@ -1446,12 +1467,14 @@ static void PrintUsage() {
14461467
std::cerr << "Options:\n";
14471468
std::cerr << " --strict Strict mode: warnings become errors\n";
14481469
std::cerr << " --no-strict Normal mode: warnings are non-fatal\n";
1449-
std::cerr << " --json Output in JSON format\n\n";
1470+
std::cerr << " --json Output in JSON format\n";
1471+
std::cerr << " --allow-legacy-text-scan Allow legacy manifest text-scan fallback (only with --manifest)\n\n";
14501472

14511473
std::cerr << "Task 5 contract:\n";
14521474
std::cerr << " browser_lightweight_wasm_audit only gates a standard zip audit bundle in the browser.\n";
14531475
std::cerr << " local_cli_spz_artifact_audit audits the real .spz artifact, directory, or manifest locally.\n";
1454-
std::cerr << " compat-check --manifest accepts legacy path scans and structured items[] with scene_id/group/split/difficulty.\n";
1476+
std::cerr << " compat-check --manifest defaults to structured items[] with scene_id/group/split/difficulty.\n";
1477+
std::cerr << " legacy path text-scan fallback is disabled by default; pass --allow-legacy-text-scan to enable.\n";
14551478
std::cerr << " browser_to_cli_handoff is optional, only merged into compat-check --json output.\n";
14561479
std::cerr << " final verdict still comes from the local CLI artifact audit.\n";
14571480
std::cerr << " spz_gatekeeper does not audit GLB or spz2glb.\n\n";
@@ -1465,6 +1488,7 @@ static void PrintUsage() {
14651488
std::cerr << " spz_gatekeeper compat-check model.spz --handoff browser_audit.json --json\n";
14661489
std::cerr << " spz_gatekeeper compat-check --dir ./fixtures --json\n";
14671490
std::cerr << " spz_gatekeeper compat-check --manifest ./fixtures/manifest.json --json\n";
1491+
std::cerr << " spz_gatekeeper compat-check --manifest ./fixtures/legacy_manifest.json --allow-legacy-text-scan --json\n";
14681492

14691493

14701494
std::cerr << " spz_gatekeeper compat-board --json\n";

0 commit comments

Comments
 (0)