{"record":{"id":"f983bf43829e2d5f","repo":"Hmbown/CodeWhale","slug":"continue-goal-with-a-wire-supplied-schedule-id-still-parses","errorCode":null,"errorMessage":"continue_goal with a wire-supplied schedule id still parses","messagePattern":"continue_goal with a wire-supplied schedule id still parses","errorType":"panic","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"crates/protocol/src/op.rs","lineNumber":709,"sourceCode":"\n    #[test]\n    fn every_variant_is_listed_once() {\n        let kinds: Vec<&str> = every_variant().iter().map(Op::kind_str).collect();\n        assert_eq!(kinds, OP_KINDS, \"OP_KINDS must list every variant in order\");\n    }\n\n    #[test]\n    fn wire_supplied_engine_schedule_id_is_dropped() {\n        // The engine mints these on an in-process channel, so a value arriving\n        // through serde came from an out-of-process caller. Honouring it would\n        // let a guessed counter consume the pending schedule and skip the\n        // host-injected quiet period.\n        let op: Op = serde_json::from_value(json!({\n            \"kind\": \"continue_goal\",\n            \"dynamic_tools\": [],\n            \"engine_schedule_id\": 3,\n        }))\n        .expect(\"continue_goal with a wire-supplied schedule id still parses\");\n        match op {\n            Op::ContinueGoal {\n                engine_schedule_id, ..\n            } => assert_eq!(\n                engine_schedule_id, None,\n                \"engine_schedule_id must never be settable from the wire\"\n            ),\n            other => panic!(\"expected ContinueGoal, got {other:?}\"),\n        }\n    }\n\n    #[test]\n    fn every_variant_round_trips_and_tags_by_kind() {\n        for op in every_variant() {\n            let value = serde_json::to_value(&op).unwrap();\n            assert_eq!(value[\"kind\"], op.kind_str(), \"{op:?}\");\n            let back: Op = serde_json::from_value(value).unwrap();\n            assert_eq!(back, op);","sourceCodeStart":691,"sourceCodeEnd":727,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/433685b2024e7bc4c99e1e2e326bcad39b4d9d65/crates/protocol/src/op.rs#L691-L727","documentation":"A test assertion panic from `serde_json::from_value::<Op>(...).expect(...)`: deserializing a `continue_goal` Op that includes a wire-supplied `engine_schedule_id` must still succeed, with serde silently dropping that field. The schedule id is host-injected on an in-process channel; accepting it from the wire would let a guessed counter hijack the pending schedule and skip the host-injected quiet period. The expect fails if the deserializer rejects the unknown/malformed field or the payload no longer maps to Op::ContinueGoal.","triggerScenarios":"serde_json::from_value on {\"kind\":\"continue_goal\",\"dynamic_tools\":[],\"engine_schedule_id\":3}: panics if the Op enum's serde representation changed (e.g. kind tag renamed, `dynamic_tools` no longer a defaultable/required field) or if an over-strict deny_unknown_fields/deserializer now errors on the extra `engine_schedule_id` key instead of ignoring it.","commonSituations":"Refactoring the Op enum's serde tagging (tag/deny_unknown_fields changes) breaking this security regression test; renaming `dynamic_tools` or changing ContinueGoal fields; accidentally making engine_schedule_id deserializable from the wire, which the follow-up assert then catches as a security bug.","solutions":["Ensure ContinueGoal's serde attributes ignore unknown fields (no deny_unknown_fields) so the wire-supplied engine_schedule_id is dropped rather than rejected.","Verify engine_schedule_id is #[serde(skip_deserializing)] or not a deserialization target at all, so the field is always None from the wire.","If the Op's kind tag or required fields changed, update the fixture JSON in the test to the current wire shape."],"exampleFix":"// before (would error or accept the field)\n#[derive(Serialize, Deserialize)]\n#[serde(tag = \"kind\", deny_unknown_fields)]\npub enum Op { ContinueGoal { engine_schedule_id: Option<u64> } }\n// after\n#[derive(Serialize, Deserialize)]\n#[serde(tag = \"kind\")]\npub enum Op {\n    ContinueGoal {\n        #[serde(skip_deserializing)]\n        engine_schedule_id: Option<u64>,\n    }\n}","handlingStrategy":"validation","validationCode":"// confirm the Op payload parses and the sensitive field is absent\nlet parsed: Result<Op, _> = serde_json::from_value(json!({\n    \"kind\": \"continue_goal\",\n    \"dynamic_tools\": [],\n    \"engine_schedule_id\": 3,\n}));\nassert!(parsed.is_ok(), \"Op failed to parse: {:?}\", parsed.err());","typeGuard":"fn is_continue_goal(op: &Op) -> Option<&Option<u64>> {\n    match op {\n        Op::ContinueGoal { engine_schedule_id, .. } => Some(engine_schedule_id),\n        _ => None,\n    }\n}","tryCatchPattern":"let op: Op = serde_json::from_value(value)\n    .unwrap_or_else(|e| panic!(\"continue_goal parse failed: {e}\"));","preventionTips":["Never place host-only fields (engine_schedule_id) in the deserialization target set; use #[serde(skip_deserializing)].","Avoid deny_unknown_fields on wire-facing enums so untrusted extra keys are ignored, not fatal.","Add a regression test any time the Op serde tagging strategy changes (internally vs externally tagged)."],"tags":["rust","serde","security","test-panic","wire-format"],"backgroundTag":"json-unmarshal-failed","analyzedSha":"433685b2024e7bc4c99e1e2e326bcad39b4d9d65","analyzedAt":"2026-09-15T12:24:24.634Z","contentChangedAt":"2026-09-15T12:24:24.634Z","schemaVersion":2},"datasetVersion":"2026-09-22T10:30:35.592Z"}