File size: 6,164 Bytes
4879fc7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
from __future__ import annotations

import datetime
import io
from typing import Any

import yaml

from app.config import Settings
from app.errors import InvalidFrontmatter


_DELIM = "---"


def parse(text: str) -> tuple[dict[str, Any], str]:
    if not text.startswith(_DELIM):
        return {}, text
    rest = text[len(_DELIM):].lstrip("\n")
    end = rest.find(f"\n{_DELIM}")
    if end == -1:
        return {}, text
    fm_text = rest[:end]
    body = rest[end + len(_DELIM) + 1 :]
    if body.startswith("\n"):
        body = body[1:]
    try:
        data = yaml.safe_load(fm_text) or {}
    except yaml.YAMLError as e:
        raise InvalidFrontmatter(f"could not parse YAML frontmatter: {e}")
    if not isinstance(data, dict):
        raise InvalidFrontmatter("frontmatter must be a mapping")
    return data, body


def serialise(fm: dict[str, Any], body: str) -> str:
    buf = io.StringIO()
    buf.write(_DELIM)
    buf.write("\n")
    yaml.safe_dump(fm, buf, sort_keys=False, default_flow_style=False, allow_unicode=True)
    buf.write(_DELIM)
    buf.write("\n")
    if body:
        if not body.startswith("\n"):
            buf.write("\n")
        buf.write(body)
        if not body.endswith("\n"):
            buf.write("\n")
    return buf.getvalue()


def merge(client_fm: dict[str, Any], server_fm: dict[str, Any]) -> dict[str, Any]:
    """Server-stamped fields always win; client fields fill in the rest."""
    merged = dict(client_fm)
    merged.update(server_fm)
    return merged


# Exactly the frontmatter keys the system itself writes onto a message: `type`
# and `refs` come from the client, `agent`/`timestamp`/`via` are server-stamped
# over whatever arrived, and `broadcast`/`channel` are server-owned (the routes
# reject them from clients with a more specific error before we get here — they
# are listed so this set stays an honest inventory of what a message file can
# contain, and so re-posting a message the API itself served still round-trips).
#
# The allowlist exists because message frontmatter is author-controlled and ends
# up inside the very JSON a watcher parses. In eq2 the client scanned responses
# for `"filename":"..."` anywhere, so one post carrying a
# `filename: 99999999-…zzz.md` key could pin every watcher's cursor past all
# future mail, permanently. The client-side fix is to read only the top-level
# server-computed `cursor` (WATCH_DESIGN.md §4.4); this is the other half, and
# it is the half that holds even against a client that gets it wrong: no
# response-shaped name (`filename`, `cursor`, `next`, `watch`) can ever appear
# in serialised frontmatter.
#
# The key allowlist alone is not enough: `yaml.safe_load` happily turns a
# mapping-valued key (e.g. `type: {cursor: 99999999-…zzz.md}`) into a nested
# dict, and that dict's *keys* serialise as raw JSON object keys — untouched by
# the JSON-string-escaping that makes ordinary string values safe. So every
# frontmatter value must also be a YAML scalar (`refs` is the one exception,
# where a list of scalars is the client-facing shape); rejecting non-scalar
# values is what actually makes the "no response-shaped name can ever appear
# in serialised frontmatter" invariant hold, rather than just holding for
# top-level keys.
MESSAGE_FRONTMATTER_KEYS = frozenset(
    {"type", "refs", "agent", "timestamp", "via", "broadcast", "channel"}
)

# yaml.safe_load's scalar result types (it also produces datetime.date/
# datetime.datetime for bare-looking dates and timestamps, not just str).
_SCALAR_TYPES = (str, int, float, bool, type(None), datetime.date, datetime.datetime)


def _is_scalar(value: Any) -> bool:
    return isinstance(value, _SCALAR_TYPES)


def validate_message_frontmatter(fm: dict[str, Any]) -> None:
    """Reject client-supplied message frontmatter outside the allowlist, naming
    the offending key (WATCH_DESIGN.md §5.5); also reject any value that is not
    a YAML scalar (`refs` may be a list of scalars) — see the module comment
    above for why non-scalar values are the other half of the vulnerability."""
    for key, value in fm.items():
        if key not in MESSAGE_FRONTMATTER_KEYS:
            raise InvalidFrontmatter(
                f"frontmatter key {key!r} is not allowed on a message; allowed "
                f"keys: {', '.join(sorted(MESSAGE_FRONTMATTER_KEYS))} — put "
                "anything else in the body"
            )
        values = value if key == "refs" and isinstance(value, (list, tuple)) else (value,)
        if not all(_is_scalar(v) for v in values):
            raise InvalidFrontmatter(
                f"frontmatter value for {key!r} must be a scalar; lists are "
                "allowed only for 'refs' and only of scalars"
            )


ALLOWED_RESULT_STATUS = {"agent-run", "negative"}


def validate_result_frontmatter(settings: Settings, fm: dict[str, Any]) -> None:
    """Validate against the challenge's configured result schema.

    The score field must be a positive number; `status` (when required) must
    be agent-run|negative; every other required field must be a non-empty
    string (or at least present, for non-string values).
    """
    for field in settings.required_result_field_list:
        if field not in fm:
            raise InvalidFrontmatter(f"result frontmatter missing required field: {field}")

    score_val = fm[settings.score_field]
    if isinstance(score_val, bool) or not isinstance(score_val, (int, float)) or score_val <= 0:
        raise InvalidFrontmatter(
            f"`{settings.score_field}` must be a positive number ({settings.score_unit})"
        )

    if "status" in settings.required_result_field_list:
        if fm["status"] not in ALLOWED_RESULT_STATUS:
            raise InvalidFrontmatter(
                f"`status` must be one of {sorted(ALLOWED_RESULT_STATUS)}, got {fm['status']!r}"
            )

    for field in settings.required_result_field_list:
        if field in (settings.score_field, "status"):
            continue
        val = fm[field]
        if isinstance(val, str) and not val.strip():
            raise InvalidFrontmatter(f"`{field}` must be a non-empty string")