From 3fee04ea74feafe4fc35a88dc82f71f588338906 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 19 Sep 2026 11:29:37 +0900 Subject: [PATCH 1/4] fix(cli): admit a mapping on `version` alone only as the policy version this build reads `load_policy` admitted any mapping that named `version`, and `version` is the one recognised key other formats use: an unquoted `version: 3` at the top of a docker-compose.yml loaded as a 0-rule policy whose source `gateway --config` served at `GET /api/policy`. Dropping `version` from the name rule would refuse a file containing only `version: 1`, a policy the gateway starts on, so the read now refuses on the value instead: when `version` is the only recognised key, it admits the document only as `POLICY_VERSION` (1). Beside `egress`, `endpoints` or `rules` the value is not consulted, so forward-compatibility of fields and serde's own diagnosis of a mistyped value are unchanged. Pinned on the binary in both directions: the compose file is refused on all three commands with nothing from the file in the message, and `version: 1` alone still validates and starts the gateway. The wiki paragraph that recorded the residual now records the rule, and llms-full.txt is regenerated. Refs #240 --- crates/honmoon-cli/src/main.rs | 321 ++++++++++++++++---- crates/honmoon-cli/tests/policy_validate.rs | 103 ++++++- wiki/getting-started/policy-authoring.md | 45 +-- wiki/llms-full.txt | 45 +-- 4 files changed, 421 insertions(+), 93 deletions(-) diff --git a/crates/honmoon-cli/src/main.rs b/crates/honmoon-cli/src/main.rs index b650194f..b7c806c1 100644 --- a/crates/honmoon-cli/src/main.rs +++ b/crates/honmoon-cli/src/main.rs @@ -1039,6 +1039,15 @@ fn hook_salt_for(context: Option<&str>, wire_salt: Vec, machine_key: Vec /// anybody mistyped. Putting it here also keeps `Policy` itself untouched, which /// is what keeps this off the `crates/AGENTS.md` **Ask first** list and out of /// TD-001's TS-type and JSON Schema sync. +/// +/// [`admitted_only_by_an_unknown_version`] runs third and is the second rule's +/// residual closed (#240): a mapping whose only recognised key is `version` +/// with a value other than [`POLICY_VERSION`] — an unquoted `version: 3` at the +/// top of a `docker-compose.yml` — was admitted by name and refused by nothing. +/// It runs after the name rule so that a file with no `version` at all gets the +/// message about missing fields, and it moves a verdict for the same reason and +/// on the same terms: `Policy` is untouched, and a file containing only +/// `version: 1` goes on loading. fn load_policy(path: &Path) -> Result<(Policy, String)> { let src = std::fs::read_to_string(path) .with_context(|| format!("reading policy {}", path.display()))?; @@ -1060,6 +1069,15 @@ fn load_policy(path: &Path) -> Result<(Policy, String)> { ); } + if admitted_only_by_an_unknown_version(&src) { + anyhow::bail!( + "{} is not a policy document: `version` is the only policy field it \ + declares, and its value is not the policy version this build reads \ + ({POLICY_VERSION}). Contents withheld — check the path.", + path.display() + ); + } + let policy = Policy::from_yaml(&src)?; Ok((policy, src)) } @@ -1231,19 +1249,19 @@ const POLICY_FIELDS: [&str; 4] = ["version", "egress", "endpoints", "rules"]; /// read from here on — fail-closed, and the operator is told which keys are /// missing, but it is a behaviour change and not only a refusal of bad input. /// -/// **The residual, stated because the rule looks tighter than it is.** This is a -/// name-only test, and `version` is the one of the four that is not -/// honmoon-specific: a `docker-compose.yml` opens with an unquoted `version: 3`, -/// so it is admitted and still loads as a 0-rule policy whose source `gateway` -/// serves. Measured, and pinned by -/// `version_alone_admits_a_file_no_operator_wrote_as_a_policy` so it cannot drift -/// unnoticed. It is not closed here because the alternative — dropping `version` -/// from the admission set — refuses a file containing only `version: 1`, which is -/// a policy the gateway starts on, and over-refusal is the failure that costs an -/// operator an outage rather than a diagnosis. The quoted spelling -/// (`version: "3.8"`) does not even reach this rule: `version` is a `u32`, so the -/// loader refuses it and quotes only the author's own three characters. Narrowing -/// the ticket is its own decision, filed rather than taken in passing. +/// **The residual this rule had, and where it is closed.** This is a name-only +/// test, and `version` is the one of the four that is not honmoon-specific: a +/// `docker-compose.yml` opens with an unquoted `version: 3`, so by name alone +/// it was admitted and loaded as a 0-rule policy whose source `gateway` served +/// (#240). It is not closed by dropping `version` from the set — that refuses a +/// file containing only `version: 1`, a policy the gateway starts on, and +/// over-refusal is the failure that costs an operator an outage rather than a +/// diagnosis. [`admitted_only_by_an_unknown_version`] closes it on the +/// *value* instead, after this rule has run: `version` admits a document on +/// its own only as [`POLICY_VERSION`]. So "one recognised key admits the +/// document whatever else it carries" holds for `egress`, `endpoints`, `rules` +/// and `version: 1`, and this function's tests read it as a statement about +/// this function. fn mapping_names_no_policy_field(src: &str) -> bool { first_document(src).is_some_and(|value| names_no_policy_field(&value)) } @@ -1275,6 +1293,104 @@ fn names_no_policy_field(value: &serde_yaml::Value) -> bool { } } +/// The policy schema version this build reads — the one value of `version` +/// that says "honmoon policy" rather than "some file with a version line". +/// +/// Written out here for the same reason [`POLICY_FIELDS`] is: nothing in +/// `honmoon-core` declares it. `Policy::version` is a plain `u32` with no +/// check on its value, the JSON Schema says only `>= 1`, and the shipped +/// example and every policy in this repository declare `1`. +/// `only_this_builds_policy_version_is_an_admission_ticket` pins the value, so +/// a schema bump that leaves this behind fails a test rather than refusing the +/// bumped file at a deploy. +const POLICY_VERSION: u32 = 1; + +/// A mapping whose only recognised key is `version`, carrying a value that is +/// not [`POLICY_VERSION`] — a file admitted by a name it uses for something +/// else (#240). +/// +/// [`mapping_names_no_policy_field`] tests by name, and `version` is the one of +/// [`POLICY_FIELDS`] that is not honmoon-specific: a `docker-compose.yml` opens +/// with an unquoted `version: 3`, so by name it was admitted and loaded as a +/// 0-rule policy whose source `gateway --config` served — #220's consequence on +/// a narrower file class than the three that issue measured. The obvious +/// narrowing, dropping `version` from the name rule, refuses a file containing +/// only `version: 1`, which is a policy the gateway starts on; refusing a policy +/// the gateway runs is the failure that costs an operator an outage rather than +/// a diagnosis, so it was not taken. This rule turns on the *value* instead: +/// `version` admits a document only as the version this build reads. +/// +/// It answers only when `version` is the **sole** ticket. Beside `egress`, +/// `endpoints` or `rules` the value is not consulted — the loader then has +/// something to run, and its own diagnosis of a bad value (`version: "1.0"` +/// quoted, with a line and column) is the useful one, exactly as before. A +/// mapping the name rule already refuses is not this rule's to answer, so the +/// two refusals cannot both fire on one file; the ordering in [`load_policy`] +/// is what `another_policy_field_admits_the_document_whatever_version_says` +/// relies on. +/// +/// What "unknown" covers is every value but the integer [`POLICY_VERSION`]: +/// another integer (compose's `2` and `3`, and the `0` an absent `version` +/// defaults to, which the schema forbids), and every spelling the loader would +/// refuse on its own — quoted, fractional, a word, `null`, a list. For those +/// the loader refuses anyway, so only the message moves: from serde quoting +/// the value to a content-free refusal for the path. For the integers the +/// loader takes them as an empty policy, so this moves a verdict on purpose, +/// the way the name rule does; `a_compose_file_is_refused_and_a_version_one_policy_is_not` +/// asks the loader as well so that stays measured rather than assumed. +/// +/// Two boundaries are kept, and pinned: +/// +/// - `version: 1` alone still loads, with or without a sibling this build has +/// never heard of. Forward-compatibility of *fields* is untouched. +/// - A policy that declares a schema version this build does not implement +/// **and** nothing this build reads is refused. That file loaded before, as +/// deny-all with no rules — a policy this build could not run as written, +/// which is #220's class — and the message names the version rule rather +/// than the path alone, so a rollback that lands here is a diagnosis. One +/// that also carries `egress`, `endpoints` or `rules` is admitted on those, +/// which is what `a_policy_carrying_an_unknown_field_still_loads` runs +/// through the binary. +/// +/// The residual of the residual is a foreign file that opens with an unquoted +/// `version: 1` and declares nothing else honmoon reads. It is admitted, and +/// nothing about the value can tell it from the minimal policy; that is the +/// bound this rule stops at, stated in `wiki/getting-started/policy-authoring.md`. +fn admitted_only_by_an_unknown_version(src: &str) -> bool { + first_document(src).is_some_and(|value| only_an_unknown_version_admits(&value)) +} + +/// The recursive half of [`admitted_only_by_an_unknown_version`], split out +/// for the tag exactly as [`names_no_policy_field`] is. +/// +/// `Value::as_u64` reads through a tag on the *value* the same way `as_str` +/// reads through one on a key (`untag_ref`, `serde_yaml` 0.9); whether the +/// loader takes `version: !t 1` is not claimed here, only that a document is +/// not refused for the tag alone. +fn only_an_unknown_version_admits(value: &serde_yaml::Value) -> bool { + use serde_yaml::Value; + + match value { + Value::Mapping(mapping) => { + let mut version = None; + for (key, value) in mapping { + match key.as_str() { + Some("version") => version = Some(value), + // Any other recognised key admits the document by itself, + // whatever `version` says. + Some(key) if POLICY_FIELDS.contains(&key) => return false, + _ => {} + } + } + // No `version` at all is the name rule's file, already refused. + version.is_some_and(|value| value.as_u64() != Some(u64::from(POLICY_VERSION))) + } + Value::Tagged(tagged) => only_an_unknown_version_admits(&tagged.value), + // Not a mapping: `not_a_policy_document` has already answered. + _ => false, + } +} + /// `honmoon policy validate` — load a policy the way the gateway does, say what /// the loader found, and exit. /// @@ -1787,59 +1903,162 @@ mod tests { ); } - /// The rule's residual, measured rather than left to be discovered. + /// #240: the name rule's residual, closed on the *value* of `version`. /// - /// The admission test is by *name*, and `version` is the one of the four keys - /// that other config formats also use. A `docker-compose.yml` opens with an - /// unquoted `version: 3`, so it is admitted, loads as a 0-rule policy, and - /// under `gateway --config` its source — inline environment secrets included — - /// is what `GET /api/policy` serves. That is #220's consequence on a narrower - /// file class than the three the issue measured, and this test exists so the - /// gap is a recorded fact with a failing test behind any change to it, rather - /// than a surprise for whoever finds it next. + /// `version` is the one of the four keys other config formats also use, so + /// by name alone a `docker-compose.yml` (`version: 3` + `services:`) was + /// admitted and loaded as a 0-rule policy whose source `gateway --config` + /// served. The loader still takes it — asserted, so this stays the read's + /// own refusal — and the read now refuses it: `version` admits a document + /// only as [`POLICY_VERSION`], and this file declares nothing else. /// - /// Kept rather than closed on purpose: dropping `version` from - /// [`POLICY_FIELDS`] would refuse a file containing only `version: 1`, a - /// policy the gateway starts on, and refusing a policy the gateway runs is the - /// worse direction. If this test ever starts failing because the ticket was - /// narrowed deliberately, delete it — do not weaken it. + /// Both halves of the trap the issue names are here. Dropping `version` + /// from [`POLICY_FIELDS`] would have refused `version: 1` alone, a policy + /// the gateway starts on; the value rule keeps it, with or without a + /// sibling this build has never heard of. #[test] - fn version_alone_admits_a_file_no_operator_wrote_as_a_policy() { - use super::mapping_names_no_policy_field; + fn a_compose_file_is_refused_and_a_version_one_policy_is_not() { + use super::{admitted_only_by_an_unknown_version, mapping_names_no_policy_field}; use honmoon_core::Policy; let compose = "version: 3\nservices:\n db:\n environment:\n \ POSTGRES_PASSWORD: throwaway-not-a-real-value\n"; assert!( !mapping_names_no_policy_field(compose), - "`version` is a recognised key, so this is admitted — the residual, \ - not a bug in the check" + "by name `version` admits it — which is why the value rule exists" ); - let policy = Policy::from_yaml(compose).expect("and it loads"); + assert!( + admitted_only_by_an_unknown_version(compose), + "…and the value rule refuses it: 3 is not this build's policy version" + ); + let policy = Policy::from_yaml(compose).expect("the loader takes it"); assert_eq!( (policy.rules.len(), policy.endpoints.len()), (0, 0), - "as a policy that enforces nothing anybody wrote" + "as a policy that enforces nothing anybody wrote — #220's case, so the \ + refusal has to be the read's" ); - // The spelling that does not reach this rule at all, and the reason the - // residual is narrower than "every compose file": `version` is a `u32`, - // so the loader refuses the quoted form and quotes only those three - // characters — the author's own field value, which is the documented - // bound rather than a leak. - let quoted = "version: \"3.8\"\nservices:\n db:\n image: postgres\n"; - assert!( - !mapping_names_no_policy_field(quoted), - "still admitted by name — the refusal below is the loader's" - ); - let error = Policy::from_yaml(quoted) - .expect_err("`version` is a u32") - .to_string(); - assert!( - error.contains("3.8") && !error.contains("postgres"), - "the loader quotes the offending value and not the rest of the \ - file: {error}" - ); + for src in [ + // The trap: a policy the gateway starts on, and the reason `version` + // could not simply leave the admission set. + "version: 1\n", + // Forward-compatibility, kept: a sibling this build does not know is + // not consulted when the version is the one it reads. + "version: 1\ntelemetry:\n exporter: otlp\n", + // A tagged mapping is a mapping, and the value is read through a + // tag on the document the way `mapping_names_no_policy_field` does. + "!Foo {version: 1}\n", + ] { + assert!( + !admitted_only_by_an_unknown_version(src), + "`version: 1` is an admission ticket: {src:?}" + ); + assert!( + Policy::from_yaml(src).is_ok(), + "…for a file the loader takes: {src:?}" + ); + } + } + + /// The value rule answers only when `version` is the *sole* ticket. + /// + /// Any other recognised key admits the document whatever `version` says, + /// because the loader then has something to run and its own diagnosis for + /// the value is the useful one — `version: "1.0"` beside `rules` still + /// reaches serde, which quotes the author's own three characters with a + /// line and column. And a mapping the name rule already refuses is not this + /// rule's to answer, so the two messages cannot both fire on one file. + #[test] + fn another_policy_field_admits_the_document_whatever_version_says() { + use super::admitted_only_by_an_unknown_version; + + for src in [ + "version: 3\negress:\n default: deny\n", + "version: \"1.0\"\nrules: []\n", + "endpoints: {}\nversion: 0\n", + // No `version` at all: nothing for this rule to weigh. + "rules: []\n", + ] { + assert!( + !admitted_only_by_an_unknown_version(src), + "a recognised key other than `version` admits it: {src:?}" + ); + } + + for src in [ + // The name rule's own files, including the one whose only key is a + // near miss of `version`. + "apiVersion: v1\nkind: Secret\n", + "Version: 3\n", + "{}\n", + // Not a mapping: `not_a_policy_document` owns these. + "", + "- version: 3\n", + "version\n", + ] { + assert!( + !admitted_only_by_an_unknown_version(src), + "a file an earlier guard answers for is not this rule's: {src:?}" + ); + } + } + + /// What "unknown" covers, spelled out per value so the boundary is a list + /// rather than an adjective. + /// + /// Every entry is a mapping whose only recognised key is `version`, so the + /// verdict turns on the value alone. Each is asked of the loader too, in + /// whichever direction it answers: the integers load as an empty policy + /// (#220's shape, so refusing them moves a verdict on purpose), and the + /// quoted and fractional spellings are ones the loader refuses anyway — + /// there the value rule changes only the message, from serde's quoting of + /// the value to a content-free refusal for the path. + #[test] + fn only_this_builds_policy_version_is_an_admission_ticket() { + use super::{POLICY_VERSION, admitted_only_by_an_unknown_version}; + use honmoon_core::Policy; + + assert_eq!(POLICY_VERSION, 1, "the shipped example policy declares 1"); + + for src in [ + // compose v2 and v3, unquoted. + "version: 2\nservices: {}\n", + "version: 3\n", + // The default an absent `version` takes; the schema says `>= 1`. + "version: 0\n", + ] { + assert!( + admitted_only_by_an_unknown_version(src), + "an integer other than {POLICY_VERSION} admits nothing: {src:?}" + ); + let policy = Policy::from_yaml(src) + .unwrap_or_else(|error| panic!("the loader takes {src:?}: {error}")); + assert_eq!( + policy.rules.len(), + 0, + "…as an empty policy, which is why refusing it is #220's case" + ); + } + + for src in [ + "version: \"3.8\"\nservices: {}\n", + "version: 3.8\n", + "version: \"1\"\n", + "version: one\n", + "version: null\n", + "version: [1]\n", + ] { + assert!( + admitted_only_by_an_unknown_version(src), + "a value that is not the integer {POLICY_VERSION} admits nothing: {src:?}" + ); + assert!( + Policy::from_yaml(src).is_err(), + "…and the loader refuses this spelling on its own, so only the \ + message moves: {src:?}" + ); + } } #[test] diff --git a/crates/honmoon-cli/tests/policy_validate.rs b/crates/honmoon-cli/tests/policy_validate.rs index 3545f05f..09dcb723 100644 --- a/crates/honmoon-cli/tests/policy_validate.rs +++ b/crates/honmoon-cli/tests/policy_validate.rs @@ -663,7 +663,12 @@ fn no_command_accepts_a_mapping_that_declares_no_policy_field() { NOT_A_POLICY_SERVICE_ACCOUNT, ), ] { - no_command_takes_it(fixture, name, contents); + no_command_takes_it( + fixture, + name, + contents, + "none of its keys is a policy field", + ); } } @@ -708,7 +713,12 @@ const NOT_A_POLICY_SERVICE_ACCOUNT: &str = r#"{ /// The body of the test above, run once per fixture — the counterpart of /// `no_command_quotes`, asserting the verdict it could not. -fn no_command_takes_it(fixture: &str, name: &str, contents: &str) { +/// +/// `says` is the fragment of the refusal that names the rule which answered, +/// because two rules now refuse a mapping content-free and which one answers +/// is part of the claim: a compose file is refused for its `version`, not for +/// declaring no policy field. +fn no_command_takes_it(fixture: &str, name: &str, contents: &str, says: &str) { let home = TempHome::new(&format!("no-policy-field-{name}")); let mistyped = home.write_policy(name, contents); let path = mistyped.to_str().unwrap(); @@ -727,13 +737,13 @@ fn no_command_takes_it(fixture: &str, name: &str, contents: &str) { let output = run(&home, &args); assert!( !output.status.success(), - "`{label}` must refuse {fixture}: it is a mapping, but it declares no \ - policy field, so accepting it reports a credential file as a valid \ - 0-rule policy" + "`{label}` must refuse {fixture}: it is a mapping that declares nothing \ + this build reads as a policy, so accepting it reports a file nobody \ + wrote as a policy as a valid 0-rule one" ); let printed = format!("{}{}", stdout(&output), stderr(&output)); assert!( - printed.contains("none of its keys is a policy field"), + printed.contains(says), "`{label}` must say what is actually wrong with {fixture}; got: {printed}" ); // Not a repeat of `no_command_quotes`: that test's fixtures are refused @@ -749,6 +759,87 @@ fn no_command_takes_it(fixture: &str, name: &str, contents: &str) { } } +/// #240: `version` is the one recognised key other formats also use, and by +/// name alone it admitted a `docker-compose.yml`. The read now admits a +/// document on `version` only when the value is the policy version this build +/// reads, so the ordinary unquoted `version: 3` spelling is refused on every +/// command — for the path, with nothing from the file in the message. +/// +/// The `environment:` block is why this matters: under `gateway --config` the +/// admitted file's whole text reached `GET /api/policy`, inline secrets and all. +/// The reproduction on the binary before this change was +/// `policy is valid (0 rules, 0 endpoints)`, exit 0. +#[test] +fn no_command_accepts_a_compose_file_admitted_only_by_its_version() { + no_command_takes_it( + "a docker-compose file", + "compose.yml", + NOT_A_POLICY_COMPOSE, + "not the policy version this build reads", + ); +} + +/// The v2/v3-era compose spelling, with `version` unquoted. Quoted (`"3.8"`) +/// never reached the name rule — `version` is a `u32`, so the loader refused +/// it — and unquoted is what a `--config` mistyped one file over lands on. +const NOT_A_POLICY_COMPOSE: &str = "\ +version: 3 +services: + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: throwaway-not-a-real-value +"; + +/// The trap #240 names, held shut on the binary: a file containing only +/// `version: 1` is a policy the gateway starts on, and the fix for the compose +/// case must not cost it. Dropping `version` from the admission set would have; +/// refusing on the value does not. +/// +/// Through the gateway as well as `validate`, for the reason +/// `a_policy_carrying_an_unknown_field_still_loads` gives. +#[test] +fn a_policy_declaring_only_its_version_still_loads() { + let home = TempHome::new("version-only"); + let policy = home.write_policy("version-only.yaml", "version: 1\n"); + + let output = run(&home, &["policy", "validate", policy.to_str().unwrap()]); + assert!( + output.status.success(), + "`version: 1` alone is a policy the gateway starts on and must go on \ + loading; got: {}", + stderr(&output) + ); + assert!( + stderr(&output).contains("policy is valid (0 rules, 0 endpoints)"), + "…reported as the empty policy it is; got: {}", + stderr(&output) + ); + + #[cfg(unix)] + { + let gateway_home = TempHome::new("version-only-gateway"); + let gateway_policy = gateway_home.write_policy("version-only.yaml", "version: 1\n"); + let started = run( + &gateway_home, + &[ + "gateway", + "--config", + gateway_policy.to_str().unwrap(), + "--addr", + UNBINDABLE_ADDR, + ], + ); + let stderr = stderr(&started); + assert!( + stderr.contains("binding proxy"), + "the gateway must have got past the loader and failed at the bind — \ + anything about `version` here means the read refused a policy it \ + used to start on; got: {stderr}" + ); + } +} + /// The property that made "require a recognised key" the chosen option rather /// than `#[serde(deny_unknown_fields)]`, so it is the one most worth pinning. /// diff --git a/wiki/getting-started/policy-authoring.md b/wiki/getting-started/policy-authoring.md index b9c1bf92..0fdeb2de 100644 --- a/wiki/getting-started/policy-authoring.md +++ b/wiki/getting-started/policy-authoring.md @@ -415,7 +415,7 @@ those take one run each. Two properties are worth stating outright, because they are what make it usable. **It is the gateway's own loader, not a second opinion.** The command calls `load_policy` -([main.rs:1042-1065](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1042-L1065)) — the same one call +([main.rs:1051-1083](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1051-L1083)) — the same one call `honmoon gateway --config` and `honmoon run --policy` make ([main.rs:620](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L620), [main.rs:797](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L797)), and the only place in the binary that reads a policy from a path, @@ -426,10 +426,10 @@ file and require the same verdict — `validate_and_the_gateway_report_the_same_ both refuse, and `validate_and_the_gateway_accept_the_same_policy` on one both accept ([tests/policy_validate.rs](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/tests/policy_validate.rs)). -The read says two things in its own words, and they are different kinds of check. **The first +The read says three things in its own words, and they are different kinds of check. **The first refuses nothing extra**: a file whose top level is not a mapping — plain text, a list, a single value — is named as *not a policy document* rather than handed to the parser -([main.rs:1120-1122](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1120-L1122)). The loader refuses those +([main.rs:1138-1140](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1138-L1140)). The loader refuses those too; what changes is that the parser would have quoted the file to say so, and for a document that is one plain scalar the quote is the whole file. Pointed at a token file, an SSH key or a `.env` by a mistyped path, that lands in the log. @@ -451,7 +451,7 @@ with an explicit `---` is one document and loads normally. **The second does refuse something extra, on purpose.** A mapping in which none of `version`, `egress`, `endpoints` or `rules` appears is refused, and the parser would have taken it -([main.rs:1247-1276](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1247-L1276)). Every `Policy` field +([main.rs:1265-1294](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1265-L1294)). Every `Policy` field carries `#[serde(default)]` and the struct has no `deny_unknown_fields`, so *any* mapping used to deserialize into a policy with every field at its default — which means a Kubernetes `Secret` manifest, a `DB_PASSWORD: …` file and a service-account JSON key (JSON is valid YAML) each loaded, @@ -469,21 +469,30 @@ forward-compatibility is why `#[serde(deny_unknown_fields)]` was rejected for th `an_unknown_sibling_of_a_recognised_key_still_loads` pins it. An explicitly empty mapping (`{}`) is refused, which is the literal rule and not an oversight: an empty file already spells "no policy". +**The third qualifies the second on one key.** `version` is the one recognised key that is not +honmoon-specific — a `docker-compose.yml` opens with an unquoted `version: 3` — so by name alone a +compose file was admitted and loaded as a 0-rule policy whose source `gateway --config` served +([issue #240](https://github.com/pleaseai/honmoon/issues/240)). It is not closed by dropping +`version` from the admission set, because that refuses a file containing only `version: 1`, a +policy the gateway starts on. It is closed on the *value*: when `version` is the only recognised key +a mapping declares, it admits the document only as `version: 1`, the policy version this build reads +([main.rs:1359-1392](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1359-L1392)). +Any other value — compose's `2` and `3`, the `0` an absent `version` defaults to, or a spelling the +parser would refuse anyway — is refused for the path, with nothing from the file in the message. +Beside `egress`, `endpoints` or `rules` the value is not consulted, so a policy declaring a version +this build does not know still loads on the fields it does know; +`a_policy_carrying_an_unknown_field_still_loads` runs that through the binary. Two files are pinned +either side: `no_command_accepts_a_compose_file_admitted_only_by_its_version` and +`a_policy_declaring_only_its_version_still_loads`. + The bound is worth stating so it is not mistaken for a gap. A file that **is** a mapping but carries -a mistyped field value (`version: "1.0"`) still reaches the parser's quoting, and the value quoted is -the author's own field, with a line and column. That is the diagnosis they asked for; suppressing it -would turn a useful error into a useless one. - -**One residual is left open deliberately, and the rule looks tighter than it is without it.** The -admission test is by *name*, and `version` is the one of the four keys that is not honmoon-specific — -a `docker-compose.yml` opens with an unquoted `version: 3`, so it is admitted and still loads as a -0-rule policy whose source `gateway --config` serves. Closing it would mean dropping `version` from -the admission set, which refuses a file containing only `version: 1` — a policy the gateway starts -on — so narrowing the ticket is its own decision rather than part of this rule. It is measured and -pinned by `version_alone_admits_a_file_no_operator_wrote_as_a_policy`, and tracked in -[issue #240](https://github.com/pleaseai/honmoon/issues/240). The quoted spelling (`version: "3.8"`) -does not reach the rule at all: `version` is a `u32`, so the parser refuses it and quotes only those -three characters. +a mistyped field value beside a field the parser can run (`version: "1.0"` above `rules:`) still +reaches the parser's quoting, and the value quoted is the author's own field, with a line and +column. That is the diagnosis they asked for; suppressing it would turn a useful error into a +useless one. Only a file whose sole recognised key is a mistyped `version` gets the content-free +refusal instead. And the value rule stops where the value can no longer tell: a foreign file that +opens with an unquoted `version: 1` and declares nothing else honmoon reads is admitted, because +nothing about that line distinguishes it from the minimal policy. An empty file is **not** in this class — it is a valid policy. YAML reads it as `null`, and every `Policy` field carries `#[serde(default)]` diff --git a/wiki/llms-full.txt b/wiki/llms-full.txt index 0db569a8..f7993ef9 100644 --- a/wiki/llms-full.txt +++ b/wiki/llms-full.txt @@ -3551,7 +3551,7 @@ those take one run each. Two properties are worth stating outright, because they are what make it usable. **It is the gateway's own loader, not a second opinion.** The command calls `load_policy` -([main.rs:1042-1065](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1042-L1065)) — the same one call +([main.rs:1051-1083](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1051-L1083)) — the same one call `honmoon gateway --config` and `honmoon run --policy` make ([main.rs:620](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L620), [main.rs:797](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L797)), and the only place in the binary that reads a policy from a path, @@ -3562,10 +3562,10 @@ file and require the same verdict — `validate_and_the_gateway_report_the_same_ both refuse, and `validate_and_the_gateway_accept_the_same_policy` on one both accept ([tests/policy_validate.rs](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/tests/policy_validate.rs)). -The read says two things in its own words, and they are different kinds of check. **The first +The read says three things in its own words, and they are different kinds of check. **The first refuses nothing extra**: a file whose top level is not a mapping — plain text, a list, a single value — is named as *not a policy document* rather than handed to the parser -([main.rs:1120-1122](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1120-L1122)). The loader refuses those +([main.rs:1138-1140](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1138-L1140)). The loader refuses those too; what changes is that the parser would have quoted the file to say so, and for a document that is one plain scalar the quote is the whole file. Pointed at a token file, an SSH key or a `.env` by a mistyped path, that lands in the log. @@ -3587,7 +3587,7 @@ with an explicit `---` is one document and loads normally. **The second does refuse something extra, on purpose.** A mapping in which none of `version`, `egress`, `endpoints` or `rules` appears is refused, and the parser would have taken it -([main.rs:1247-1276](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1247-L1276)). Every `Policy` field +([main.rs:1265-1294](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1265-L1294)). Every `Policy` field carries `#[serde(default)]` and the struct has no `deny_unknown_fields`, so *any* mapping used to deserialize into a policy with every field at its default — which means a Kubernetes `Secret` manifest, a `DB_PASSWORD: …` file and a service-account JSON key (JSON is valid YAML) each loaded, @@ -3605,21 +3605,30 @@ forward-compatibility is why `#[serde(deny_unknown_fields)]` was rejected for th `an_unknown_sibling_of_a_recognised_key_still_loads` pins it. An explicitly empty mapping (`{}`) is refused, which is the literal rule and not an oversight: an empty file already spells "no policy". +**The third qualifies the second on one key.** `version` is the one recognised key that is not +honmoon-specific — a `docker-compose.yml` opens with an unquoted `version: 3` — so by name alone a +compose file was admitted and loaded as a 0-rule policy whose source `gateway --config` served +([issue #240](https://github.com/pleaseai/honmoon/issues/240)). It is not closed by dropping +`version` from the admission set, because that refuses a file containing only `version: 1`, a +policy the gateway starts on. It is closed on the *value*: when `version` is the only recognised key +a mapping declares, it admits the document only as `version: 1`, the policy version this build reads +([main.rs:1359-1392](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1359-L1392)). +Any other value — compose's `2` and `3`, the `0` an absent `version` defaults to, or a spelling the +parser would refuse anyway — is refused for the path, with nothing from the file in the message. +Beside `egress`, `endpoints` or `rules` the value is not consulted, so a policy declaring a version +this build does not know still loads on the fields it does know; +`a_policy_carrying_an_unknown_field_still_loads` runs that through the binary. Two files are pinned +either side: `no_command_accepts_a_compose_file_admitted_only_by_its_version` and +`a_policy_declaring_only_its_version_still_loads`. + The bound is worth stating so it is not mistaken for a gap. A file that **is** a mapping but carries -a mistyped field value (`version: "1.0"`) still reaches the parser's quoting, and the value quoted is -the author's own field, with a line and column. That is the diagnosis they asked for; suppressing it -would turn a useful error into a useless one. - -**One residual is left open deliberately, and the rule looks tighter than it is without it.** The -admission test is by *name*, and `version` is the one of the four keys that is not honmoon-specific — -a `docker-compose.yml` opens with an unquoted `version: 3`, so it is admitted and still loads as a -0-rule policy whose source `gateway --config` serves. Closing it would mean dropping `version` from -the admission set, which refuses a file containing only `version: 1` — a policy the gateway starts -on — so narrowing the ticket is its own decision rather than part of this rule. It is measured and -pinned by `version_alone_admits_a_file_no_operator_wrote_as_a_policy`, and tracked in -[issue #240](https://github.com/pleaseai/honmoon/issues/240). The quoted spelling (`version: "3.8"`) -does not reach the rule at all: `version` is a `u32`, so the parser refuses it and quotes only those -three characters. +a mistyped field value beside a field the parser can run (`version: "1.0"` above `rules:`) still +reaches the parser's quoting, and the value quoted is the author's own field, with a line and +column. That is the diagnosis they asked for; suppressing it would turn a useful error into a +useless one. Only a file whose sole recognised key is a mistyped `version` gets the content-free +refusal instead. And the value rule stops where the value can no longer tell: a foreign file that +opens with an unquoted `version: 1` and declares nothing else honmoon reads is admitted, because +nothing about that line distinguishes it from the minimal policy. An empty file is **not** in this class — it is a valid policy. YAML reads it as `null`, and every `Policy` field carries `#[serde(default)]` From aade03c636bd5349927bdd69bd3320dfe7b023d9 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 19 Sep 2026 11:29:37 +0900 Subject: [PATCH 2/4] chore(agent-memory): record that the compose-file residual is closed by the version value rule (#240) --- .../policy-load-error-echoes-file.md | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/.claude/agent-memory/review-review-security-reviewer/policy-load-error-echoes-file.md b/.claude/agent-memory/review-review-security-reviewer/policy-load-error-echoes-file.md index f6d87ba3..971c33ec 100644 --- a/.claude/agent-memory/review-review-security-reviewer/policy-load-error-echoes-file.md +++ b/.claude/agent-memory/review-review-security-reviewer/policy-load-error-echoes-file.md @@ -1,6 +1,6 @@ --- name: policy-load-error-echoes-file -description: "Policy::from_yaml on a file that is not a policy echoes its whole content into the error, because serde quotes the offending scalar; closed for all three commands by the shared load_policy guard (#202), and the mapping-shaped residual (a secrets file or a JSON key loading as a valid 0-rule policy) is closed too, by load_policy refusing a mapping that declares no recognised policy key (#220) — do not report either as live" +description: "Policy::from_yaml on a file that is not a policy echoes its whole content into the error, because serde quotes the offending scalar; closed for all three commands by the shared load_policy guard (#202), the mapping-shaped residual (a secrets file or a JSON key loading as a valid 0-rule policy) is closed by load_policy refusing a mapping that declares no recognised policy key (#220), and the compose-file residual (admitted by an unquoted version: 3 alone) is closed by the value rule that admits version on its own only as 1 (#240) — do not report any of the three as live" metadata: type: project --- @@ -57,20 +57,28 @@ for #220 on exactly that ground, and the policy struct was deliberately left unt stays off the `crates/AGENTS.md` **Ask first** list. An explicitly empty mapping (`{}`) is refused and that is intended, not a bug. -**The residual of the residual, measured on the #220 build (do not re-derive).** The admission -ticket is the *name* of one of `version`, `egress`, `endpoints`, `rules`, and `version` is a generic -name other config formats use. Measured with the built binary: `version: 3` + `services:` with a -`POSTGRES_PASSWORD:` (an ordinary docker-compose file) still answers -`policy is valid (0 rules, 0 endpoints)` and, under `gateway --config`, its whole text still reaches -`AppState.policy_yaml` → `GET /api/policy`. `version: "3.8"` (quoted) does not — serde fails the -`u32` and quotes only `"3.8"`. Also measured and *not* gaps: a k8s Secret, a `KEY: value` file, a +**The residual of the residual — closed by #240, do not re-report it as live.** The name rule's +admission ticket is one of `version`, `egress`, `endpoints`, `rules`, and `version` is a generic +name other config formats use. Measured on the #220 build: `version: 3` + `services:` with a +`POSTGRES_PASSWORD:` (an ordinary docker-compose file) answered +`policy is valid (0 rules, 0 endpoints)` and, under `gateway --config`, its whole text reached +`AppState.policy_yaml` → `GET /api/policy`. `load_policy` now runs a third guard after the name +rule, `admitted_only_by_an_unknown_version` (`crates/honmoon-cli/src/main.rs`): when `version` is +the *only* recognised key a mapping declares, it admits the document only as the integer +`POLICY_VERSION` (1). Any other value — compose's `2`/`3`, `0`, or a quoted/fractional spelling — +is refused for the path with no content in the message, on all three commands. It was **not** +closed by dropping `version` from the name rule, because that refuses a file containing only +`version: 1`, a policy the gateway starts on; `a_policy_declaring_only_its_version_still_loads` +pins that on the binary. Beside `egress`/`endpoints`/`rules` the value is not consulted, so a +mistyped `version: "1.0"` above `rules:` still reaches serde's quoting of those three characters +(the documented bound, unchanged). The remaining bound is a foreign file opening with an unquoted +`version: 1` and nothing else honmoon reads — admitted, and the value cannot tell it from the +minimal policy. Also measured and *not* gaps: a k8s Secret, a `KEY: value` file, a service-account JSON, `{}`, a tagged mapping, a complex (non-string) key, `Version:`/`Rules:` case variants, a `%YAML` directive, a BOM'd file and a merge-key-only document are all refused with no content in the message; a `---\n---\n` (empty first document) does not leak either — the loader stops at "more than one document". Multi-document files never load at all, though which guard *answers* for one moved in #239: a stream whose first document is a secrets mapping is now refused by the recognised-key rule for the path, not by the loader for being a stream. -The compose case is **tracked in #240**, with the reasoning for leaving it open — dropping `version` from the admission set would refuse a file containing only `version: 1`, a policy the gateway starts on. Do not re-file it, and do not report it as an oversight in #239: it is pinned there by `version_alone_admits_a_file_no_operator_wrote_as_a_policy` and stated in `wiki/getting-started/policy-authoring.md`. - **State after #201 (historical).** `honmoon policy validate` classifies the top-level shape itself (`not_a_policy_document` in `crates/honmoon-cli/src/main.rs`) and refuses plain text, a list or a single value by name, without quoting. It refuses nothing the loader would have accepted, so From 3183ea9b45d164937c0e3d6c8d7945c4408e0491 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 19 Sep 2026 11:40:53 +0900 Subject: [PATCH 3/4] test(cli): run a quoted version-only file through the binary and scope the forward-compat test doc Review round on #283. The quoted spelling with nothing beside it was asserted only of the rule and the loader separately; it now goes through all three commands, so the order of the guards in load_policy is what the test sees. The forward-compat unit test's doc says which rule its sentence is about, since the read as a whole now refuses its first fixture on the version value. A tag on the version value joins the admitted fixtures, measured against the loader. Wiki anchors start on each item's doc comment, and the residual names a concrete file class. --- .please/docs/review/rejected-findings.jsonl | 3 ++ crates/honmoon-cli/src/main.rs | 13 +++++++- crates/honmoon-cli/tests/policy_validate.rs | 33 +++++++++++++++++---- wiki/getting-started/policy-authoring.md | 14 +++++---- wiki/llms-full.txt | 14 +++++---- 5 files changed, 58 insertions(+), 19 deletions(-) diff --git a/.please/docs/review/rejected-findings.jsonl b/.please/docs/review/rejected-findings.jsonl index d0c7a733..c3ad806f 100644 --- a/.please/docs/review/rejected-findings.jsonl +++ b/.please/docs/review/rejected-findings.jsonl @@ -16,3 +16,6 @@ {"key":"b29ecffd3dea0c38","file":"crates/honmoon-proxy/src/runtime/postgres.rs","title":"Write path now registers/cancels a tokio timer on every chunk, not just on stall","reason":"out of scope: speculative micro-cost (one O(1) timer registration per 16 KiB chunk, tens of ns) with no reachable throughput effect; the read arm already pays it per select turn by design","rejected_at":"2026-09-14T16:09:51Z","source":"code-review"} {"key":"03abffada9c6f14e","file":"crates/honmoon-cli/src/main.rs","title":"Gateway banner prints before startup can fail on token/policy resolution, but after those it always precedes bind","reason":"by design: the ordering matches every other startup line in gateway(); the reporting finder itself concludes no change is needed","rejected_at":"2026-09-14T17:06:24Z","source":"code-review"} {"key":"27133412c020e174","file":"crates/honmoon-cli/src/main.rs","title":"New eprintln! call inherits the pre-existing panic-on-closed-stderr behavior of the macro","reason":"by design: eprintln!'s panic on a closed stderr is std behavior and the convention every operator-facing line in this binary already uses","rejected_at":"2026-09-14T17:06:24Z","source":"code-review"} +{"key":"5b7bbfb452779de3","file":"crates/honmoon-cli/src/main.rs","title":"A value-side YAML tag on `version` itself (`version: !t 1`) is not exercised, only disclaimed","reason":"by-design: doc comment cites serde_yaml 0.9.34 as_u64→untag_ref, a library-guaranteed behavior; absence of a redundant application test is not a defect","rejected_at":"2026-09-19T02:37:56Z","source":"code-review"} +{"key":"a6bf5d4e0f4ba8db","file":"wiki/getting-started/policy-authoring.md","title":"The stated remaining bound (`version: 1` alone) is named but its consequence is not restated, and it is less exotic than the wording suggests","reason":"by-design: the gateway --config / GET /api/policy consequence is stated twice earlier in the same section for the same admission mechanism; deliberate non-repetition","rejected_at":"2026-09-19T02:37:56Z","source":"code-review"} +{"key":"3fb3f4142f7bc21a","file":"crates/honmoon-cli/src/main.rs","title":"Pre-existing test doc comment now ambiguous about which function 'admits the document'","reason":"by-design: the doc opens 'asked of the function directly' and the function doc added in the same diff scopes the sentence; scoping sentence added anyway","rejected_at":"2026-09-19T02:40:35Z","source":"code-review"} diff --git a/crates/honmoon-cli/src/main.rs b/crates/honmoon-cli/src/main.rs index b7c806c1..19cf92a3 100644 --- a/crates/honmoon-cli/src/main.rs +++ b/crates/honmoon-cli/src/main.rs @@ -1740,7 +1740,15 @@ mod tests { /// /// `#[serde(deny_unknown_fields)]` was rejected for #220 because it breaks /// this, so the option that was taken has to keep it exactly. One recognised - /// key admits the document and nothing about its siblings is consulted. + /// key admits the document past *this rule* and nothing about its siblings + /// is consulted. Past the read as a whole the first fixture is another + /// matter: `version: 2` is its only recognised key, so + /// [`admitted_only_by_an_unknown_version`] refuses it in [`load_policy`] + /// (#240) — deliberately, and pinned there — while the field-level + /// forward-compatibility this test is about is kept by the same rule for + /// `version: 1` (`a_compose_file_is_refused_and_a_version_one_policy_is_not`) + /// and, through the binary, for `version: 2` beside `egress` + /// (`a_policy_carrying_an_unknown_field_still_loads`). #[test] fn an_unknown_sibling_of_a_recognised_key_still_loads() { use super::mapping_names_no_policy_field; @@ -1949,6 +1957,9 @@ mod tests { // A tagged mapping is a mapping, and the value is read through a // tag on the document the way `mapping_names_no_policy_field` does. "!Foo {version: 1}\n", + // A tag on the value itself: `as_u64` untags before it reads, and + // the loader takes the same file, so the two agree. + "version: !t 1\n", ] { assert!( !admitted_only_by_an_unknown_version(src), diff --git a/crates/honmoon-cli/tests/policy_validate.rs b/crates/honmoon-cli/tests/policy_validate.rs index 09dcb723..c4aa2934 100644 --- a/crates/honmoon-cli/tests/policy_validate.rs +++ b/crates/honmoon-cli/tests/policy_validate.rs @@ -769,14 +769,30 @@ fn no_command_takes_it(fixture: &str, name: &str, contents: &str, says: &str) { /// admitted file's whole text reached `GET /api/policy`, inline secrets and all. /// The reproduction on the binary before this change was /// `policy is valid (0 rules, 0 endpoints)`, exit 0. +/// +/// The second fixture is the quoted spelling with nothing beside it. Before +/// this change the loader refused it and quoted the value; now the read +/// refuses it first, in the same content-free words, which is asserted here +/// on the binary rather than only of the two halves in isolation — the order +/// of the guards in `load_policy` is what decides which answer an operator +/// reads, and a unit test of the rule alone cannot see that order. #[test] fn no_command_accepts_a_compose_file_admitted_only_by_its_version() { - no_command_takes_it( - "a docker-compose file", - "compose.yml", - NOT_A_POLICY_COMPOSE, - "not the policy version this build reads", - ); + for (fixture, name, contents) in [ + ("a docker-compose file", "compose.yml", NOT_A_POLICY_COMPOSE), + ( + "a quoted version with nothing beside it", + "quoted-version.yaml", + NOT_A_POLICY_QUOTED_VERSION, + ), + ] { + no_command_takes_it( + fixture, + name, + contents, + "not the policy version this build reads", + ); + } } /// The v2/v3-era compose spelling, with `version` unquoted. Quoted (`"3.8"`) @@ -791,6 +807,11 @@ services: POSTGRES_PASSWORD: throwaway-not-a-real-value "; +/// A mapping whose only key is a `version` the loader cannot read as a `u32`. +/// Its one line is what `no_command_takes_it` checks the message for, so the +/// refusal has to name the rule without quoting the value. +const NOT_A_POLICY_QUOTED_VERSION: &str = "version: \"1.0\"\n"; + /// The trap #240 names, held shut on the binary: a file containing only /// `version: 1` is a policy the gateway starts on, and the fix for the compose /// case must not cost it. Dropping `version` from the admission set would have; diff --git a/wiki/getting-started/policy-authoring.md b/wiki/getting-started/policy-authoring.md index 0fdeb2de..ff57d89f 100644 --- a/wiki/getting-started/policy-authoring.md +++ b/wiki/getting-started/policy-authoring.md @@ -415,7 +415,7 @@ those take one run each. Two properties are worth stating outright, because they are what make it usable. **It is the gateway's own loader, not a second opinion.** The command calls `load_policy` -([main.rs:1051-1083](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1051-L1083)) — the same one call +([main.rs:1008-1083](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1008-L1083)) — the same one call `honmoon gateway --config` and `honmoon run --policy` make ([main.rs:620](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L620), [main.rs:797](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L797)), and the only place in the binary that reads a policy from a path, @@ -429,7 +429,7 @@ both refuse, and `validate_and_the_gateway_accept_the_same_policy` on one both a The read says three things in its own words, and they are different kinds of check. **The first refuses nothing extra**: a file whose top level is not a mapping — plain text, a list, a single value — is named as *not a policy document* rather than handed to the parser -([main.rs:1138-1140](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1138-L1140)). The loader refuses those +([main.rs:1085-1140](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1085-L1140)). The loader refuses those too; what changes is that the parser would have quoted the file to say so, and for a document that is one plain scalar the quote is the whole file. Pointed at a token file, an SSH key or a `.env` by a mistyped path, that lands in the log. @@ -451,7 +451,7 @@ with an explicit `---` is one document and loads normally. **The second does refuse something extra, on purpose.** A mapping in which none of `version`, `egress`, `endpoints` or `rules` appears is refused, and the parser would have taken it -([main.rs:1265-1294](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1265-L1294)). Every `Policy` field +([main.rs:1218-1294](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1218-L1294)). Every `Policy` field carries `#[serde(default)]` and the struct has no `deny_unknown_fields`, so *any* mapping used to deserialize into a policy with every field at its default — which means a Kubernetes `Secret` manifest, a `DB_PASSWORD: …` file and a service-account JSON key (JSON is valid YAML) each loaded, @@ -476,7 +476,7 @@ compose file was admitted and loaded as a 0-rule policy whose source `gateway -- `version` from the admission set, because that refuses a file containing only `version: 1`, a policy the gateway starts on. It is closed on the *value*: when `version` is the only recognised key a mapping declares, it admits the document only as `version: 1`, the policy version this build reads -([main.rs:1359-1392](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1359-L1392)). +([main.rs:1308-1392](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1308-L1392)). Any other value — compose's `2` and `3`, the `0` an absent `version` defaults to, or a spelling the parser would refuse anyway — is refused for the path, with nothing from the file in the message. Beside `egress`, `endpoints` or `rules` the value is not consulted, so a policy declaring a version @@ -491,8 +491,10 @@ reaches the parser's quoting, and the value quoted is the author's own field, wi column. That is the diagnosis they asked for; suppressing it would turn a useful error into a useless one. Only a file whose sole recognised key is a mistyped `version` gets the content-free refusal instead. And the value rule stops where the value can no longer tell: a foreign file that -opens with an unquoted `version: 1` and declares nothing else honmoon reads is admitted, because -nothing about that line distinguishes it from the minimal policy. +opens with an unquoted `version: 1` and declares nothing else honmoon reads — a Python +`logging.config.dictConfig` file, whose schema requires exactly that first line — is admitted, and +loaded and served the same way the compose file was, because nothing about that line +distinguishes it from the minimal policy. An empty file is **not** in this class — it is a valid policy. YAML reads it as `null`, and every `Policy` field carries `#[serde(default)]` diff --git a/wiki/llms-full.txt b/wiki/llms-full.txt index f7993ef9..fce031d7 100644 --- a/wiki/llms-full.txt +++ b/wiki/llms-full.txt @@ -3551,7 +3551,7 @@ those take one run each. Two properties are worth stating outright, because they are what make it usable. **It is the gateway's own loader, not a second opinion.** The command calls `load_policy` -([main.rs:1051-1083](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1051-L1083)) — the same one call +([main.rs:1008-1083](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1008-L1083)) — the same one call `honmoon gateway --config` and `honmoon run --policy` make ([main.rs:620](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L620), [main.rs:797](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L797)), and the only place in the binary that reads a policy from a path, @@ -3565,7 +3565,7 @@ both refuse, and `validate_and_the_gateway_accept_the_same_policy` on one both a The read says three things in its own words, and they are different kinds of check. **The first refuses nothing extra**: a file whose top level is not a mapping — plain text, a list, a single value — is named as *not a policy document* rather than handed to the parser -([main.rs:1138-1140](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1138-L1140)). The loader refuses those +([main.rs:1085-1140](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1085-L1140)). The loader refuses those too; what changes is that the parser would have quoted the file to say so, and for a document that is one plain scalar the quote is the whole file. Pointed at a token file, an SSH key or a `.env` by a mistyped path, that lands in the log. @@ -3587,7 +3587,7 @@ with an explicit `---` is one document and loads normally. **The second does refuse something extra, on purpose.** A mapping in which none of `version`, `egress`, `endpoints` or `rules` appears is refused, and the parser would have taken it -([main.rs:1265-1294](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1265-L1294)). Every `Policy` field +([main.rs:1218-1294](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1218-L1294)). Every `Policy` field carries `#[serde(default)]` and the struct has no `deny_unknown_fields`, so *any* mapping used to deserialize into a policy with every field at its default — which means a Kubernetes `Secret` manifest, a `DB_PASSWORD: …` file and a service-account JSON key (JSON is valid YAML) each loaded, @@ -3612,7 +3612,7 @@ compose file was admitted and loaded as a 0-rule policy whose source `gateway -- `version` from the admission set, because that refuses a file containing only `version: 1`, a policy the gateway starts on. It is closed on the *value*: when `version` is the only recognised key a mapping declares, it admits the document only as `version: 1`, the policy version this build reads -([main.rs:1359-1392](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1359-L1392)). +([main.rs:1308-1392](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1308-L1392)). Any other value — compose's `2` and `3`, the `0` an absent `version` defaults to, or a spelling the parser would refuse anyway — is refused for the path, with nothing from the file in the message. Beside `egress`, `endpoints` or `rules` the value is not consulted, so a policy declaring a version @@ -3627,8 +3627,10 @@ reaches the parser's quoting, and the value quoted is the author's own field, wi column. That is the diagnosis they asked for; suppressing it would turn a useful error into a useless one. Only a file whose sole recognised key is a mistyped `version` gets the content-free refusal instead. And the value rule stops where the value can no longer tell: a foreign file that -opens with an unquoted `version: 1` and declares nothing else honmoon reads is admitted, because -nothing about that line distinguishes it from the minimal policy. +opens with an unquoted `version: 1` and declares nothing else honmoon reads — a Python +`logging.config.dictConfig` file, whose schema requires exactly that first line — is admitted, and +loaded and served the same way the compose file was, because nothing about that line +distinguishes it from the minimal policy. An empty file is **not** in this class — it is a valid policy. YAML reads it as `null`, and every `Policy` field carries `#[serde(default)]` From 2b1db0897885a9df029ebdc0fc08559df8c60a79 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sat, 19 Sep 2026 11:42:34 +0900 Subject: [PATCH 4/4] test(cli): tie POLICY_VERSION to the shipped example policy rather than a second literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile on #283: asserting the constant against another 1 detects no drift. The test now parses policies/agent.yaml — the artifact a new policy is copied from — and requires its version to be POLICY_VERSION, so a schema bump that lands in the example fails here until the constant follows. The wiki anchor for the value rule moves with the const doc. --- crates/honmoon-cli/src/main.rs | 23 ++++++++++++++++++----- wiki/getting-started/policy-authoring.md | 2 +- wiki/llms-full.txt | 2 +- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/crates/honmoon-cli/src/main.rs b/crates/honmoon-cli/src/main.rs index 19cf92a3..ab573f8c 100644 --- a/crates/honmoon-cli/src/main.rs +++ b/crates/honmoon-cli/src/main.rs @@ -1298,10 +1298,12 @@ fn names_no_policy_field(value: &serde_yaml::Value) -> bool { /// /// Written out here for the same reason [`POLICY_FIELDS`] is: nothing in /// `honmoon-core` declares it. `Policy::version` is a plain `u32` with no -/// check on its value, the JSON Schema says only `>= 1`, and the shipped -/// example and every policy in this repository declare `1`. -/// `only_this_builds_policy_version_is_an_admission_ticket` pins the value, so -/// a schema bump that leaves this behind fails a test rather than refusing the +/// check on its value, and the JSON Schema says only `>= 1`. The artifact +/// that does define the version operators write is the shipped example, +/// `policies/agent.yaml`, and +/// `only_this_builds_policy_version_is_an_admission_ticket` reads that file +/// and requires its `version` to be this constant — so a schema bump that +/// lands in the example and not here fails a test rather than refusing the /// bumped file at a deploy. const POLICY_VERSION: u32 = 1; @@ -2030,7 +2032,18 @@ mod tests { use super::{POLICY_VERSION, admitted_only_by_an_unknown_version}; use honmoon_core::Policy; - assert_eq!(POLICY_VERSION, 1, "the shipped example policy declares 1"); + // The artifact that defines the version operators write, read + // mechanically rather than restated as a second literal: the shipped + // example is what a new policy is copied from, so a schema bump lands + // there, and this is where it fails until `POLICY_VERSION` follows. + let shipped = Policy::from_yaml(include_str!("../../../policies/agent.yaml")) + .expect("the shipped example policy loads"); + assert_eq!( + shipped.version, POLICY_VERSION, + "`policies/agent.yaml` declares a version `POLICY_VERSION` does not — \ + a policy copied from the example and stripped to its `version` line \ + would now be refused; update the constant, not this test" + ); for src in [ // compose v2 and v3, unquoted. diff --git a/wiki/getting-started/policy-authoring.md b/wiki/getting-started/policy-authoring.md index ff57d89f..c1e56511 100644 --- a/wiki/getting-started/policy-authoring.md +++ b/wiki/getting-started/policy-authoring.md @@ -476,7 +476,7 @@ compose file was admitted and loaded as a 0-rule policy whose source `gateway -- `version` from the admission set, because that refuses a file containing only `version: 1`, a policy the gateway starts on. It is closed on the *value*: when `version` is the only recognised key a mapping declares, it admits the document only as `version: 1`, the policy version this build reads -([main.rs:1308-1392](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1308-L1392)). +([main.rs:1310-1394](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1310-L1394)). Any other value — compose's `2` and `3`, the `0` an absent `version` defaults to, or a spelling the parser would refuse anyway — is refused for the path, with nothing from the file in the message. Beside `egress`, `endpoints` or `rules` the value is not consulted, so a policy declaring a version diff --git a/wiki/llms-full.txt b/wiki/llms-full.txt index fce031d7..a9e8f9e6 100644 --- a/wiki/llms-full.txt +++ b/wiki/llms-full.txt @@ -3612,7 +3612,7 @@ compose file was admitted and loaded as a 0-rule policy whose source `gateway -- `version` from the admission set, because that refuses a file containing only `version: 1`, a policy the gateway starts on. It is closed on the *value*: when `version` is the only recognised key a mapping declares, it admits the document only as `version: 1`, the policy version this build reads -([main.rs:1308-1392](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1308-L1392)). +([main.rs:1310-1394](https://github.com/pleaseai/honmoon/blob/main/crates/honmoon-cli/src/main.rs#L1310-L1394)). Any other value — compose's `2` and `3`, the `0` an absent `version` defaults to, or a spelling the parser would refuse anyway — is refused for the path, with nothing from the file in the message. Beside `egress`, `endpoints` or `rules` the value is not consulted, so a policy declaring a version