Skip to content

Commit df82626

Browse files
authored
Merge pull request #6209 from LIHUA919/codex/bounded-cli-json-input
2 parents c51ec1a + 864634f commit df82626

7 files changed

Lines changed: 533 additions & 25 deletions

File tree

‎loopx/capabilities/content_ops/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ still requires that owner's qualified source provider. See
5959
- Provider delivery and readback receipts must match the approved revision and
6060
digest; the lifecycle helper performs no external write.
6161
- Layout acceptance never implies content approval or publishing authority.
62+
- JSON read from stdin (`-`) is refused before parsing above 16 MiB
63+
(16,777,216 bytes); `--max-input-bytes N` sets a different positive limit on
64+
the subcommand. The limit counts input bytes, not process memory, and does
65+
not apply to JSON files.
6266
- Built-in cover pages require `0.82–0.94` meaningful vertical density; the
6367
template field is `density.role_overrides.cover`.
6468
- Built-in `page_sequence` defaults require a cover first, make it the density

‎loopx/capabilities/content_ops/cli.py‎

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import argparse
4+
import codecs
45
import json
56
import sys
67
from collections.abc import Callable
@@ -43,6 +44,49 @@
4344
render_content_ops_walkthrough_artifact_markdown,
4445
)
4546

47+
MAX_INPUT_BYTES = 16 * 1024 * 1024
48+
49+
50+
def _input_byte_limit(text: str) -> int:
51+
# An explicit, positive byte count; there is no unbounded setting.
52+
if not text.isdecimal() or int(text) < 1:
53+
raise argparse.ArgumentTypeError(f"must be a positive whole number of bytes, got {text!r}")
54+
return int(text)
55+
56+
57+
def _add_input_limit(parser: argparse.ArgumentParser) -> None:
58+
parser.add_argument(
59+
"--max-input-bytes",
60+
type=_input_byte_limit,
61+
default=MAX_INPUT_BYTES,
62+
help=(
63+
"Refuse JSON read from stdin ('-') larger than this many bytes before "
64+
f"parsing (default {MAX_INPUT_BYTES}). Bounds input size, not process memory."
65+
),
66+
)
67+
68+
69+
def _read_stdin_text(limit: int, label: str) -> str:
70+
# Read through the text stream so stdin keeps its decoding, but charge each
71+
# chunk by its encoded size: one character can take up to four bytes. One
72+
# incremental encoder spans all chunks, so a BOM is charged only once.
73+
encoding = getattr(sys.stdin, "encoding", None) or "utf-8"
74+
errors = getattr(sys.stdin, "errors", None) or "surrogatepass"
75+
encoder = codecs.getincrementalencoder(encoding)(errors)
76+
chunks: list[str] = []
77+
consumed = 0
78+
while consumed <= limit:
79+
chunk = sys.stdin.read(max(1, (limit + 1 - consumed) // 4))
80+
if not chunk:
81+
consumed += len(encoder.encode("", final=True))
82+
break
83+
chunks.append(chunk)
84+
consumed += len(encoder.encode(chunk))
85+
if consumed > limit:
86+
raise ValueError(f"{label} exceeds the {limit}-byte limit")
87+
return "".join(chunks)
88+
89+
4690
PrintPayload = Callable[
4791
[dict[str, object], str, Callable[[dict[str, object]], str]],
4892
None,
@@ -51,9 +95,9 @@
5195
AddFormat = Callable[[argparse.ArgumentParser], None]
5296

5397

54-
def _load_json_object(path_text: str) -> dict[str, Any]:
98+
def _load_json_object(path_text: str, limit: int) -> dict[str, Any]:
5599
if path_text == "-":
56-
payload = json.loads(sys.stdin.read())
100+
payload = json.loads(_read_stdin_text(limit, "content-ops stdin JSON input"))
57101
else:
58102
payload = json.loads(Path(path_text).expanduser().read_text(encoding="utf-8"))
59103
if not isinstance(payload, dict):
@@ -109,6 +153,7 @@ def register_content_ops_commands(
109153
reference_parser.add_argument("--observed-at")
110154
reference_parser.add_argument("--output-json", help="Create a new mode-0600 private result artifact; never overwrite an existing catalog.")
111155
add_subcommand_format(reference_parser)
156+
_add_input_limit(reference_parser)
112157
preview_parser = content_ops_sub.add_parser(
113158
"preview",
114159
help="Preview metadata-only connector trials and content-ops projection.",
@@ -234,6 +279,7 @@ def register_content_ops_commands(
234279
),
235280
)
236281
add_subcommand_format(aggregate_parser)
282+
_add_input_limit(aggregate_parser)
237283
aggregate_parser.add_argument(
238284
"--public-packet-json",
239285
action="append",
@@ -414,6 +460,7 @@ def register_content_ops_commands(
414460
),
415461
)
416462
add_subcommand_format(item_transition_parser)
463+
_add_input_limit(item_transition_parser)
417464
item_transition_parser.add_argument(
418465
"--item-json",
419466
required=True,
@@ -432,6 +479,7 @@ def register_content_ops_commands(
432479
),
433480
)
434481
add_subcommand_format(queue_parser)
482+
_add_input_limit(queue_parser)
435483
queue_parser.add_argument(
436484
"--item-json",
437485
action="append",
@@ -492,6 +540,7 @@ def register_content_ops_commands(
492540
help="Check rendered-page measurements against a typed layout plan.",
493541
)
494542
add_subcommand_format(layout_check_parser)
543+
_add_input_limit(layout_check_parser)
495544
layout_check_parser.add_argument(
496545
"--plan-json",
497546
required=True,
@@ -520,10 +569,10 @@ def handle_content_ops_command(
520569
raise ValueError("inventory requires a catalog file, --goal-id, --store-id and --observed-at")
521570
payload = inspect_reference_materials(library_path=Path(args.library_json).expanduser(), goal_id=args.goal_id, store_id=args.store_id, observed_at=args.observed_at)
522571
else:
523-
request = _load_json_object(args.input_json) if args.input_json else {}
572+
request = _load_json_object(args.input_json, args.max_input_bytes) if args.input_json else {}
524573
if args.operation in {"capture", "draft"} and not args.input_json:
525574
raise ValueError("capture/draft require --input-json")
526-
request["library"] = _load_json_object(args.library_json)
575+
request["library"] = _load_json_object(args.library_json, args.max_input_bytes)
527576
if args.operation == "search":
528577
request.update(query=args.query, structure=args.structure)
529578
payload = content_reference_operation(args.operation, request)
@@ -565,10 +614,10 @@ def handle_content_ops_command(
565614
elif args.content_ops_command == "aggregate-packets":
566615
payload = build_content_ops_packet_aggregation_packet(
567616
public_handle_packets=[
568-
_load_json_object(path) for path in args.public_packet_json
617+
_load_json_object(path, args.max_input_bytes) for path in args.public_packet_json
569618
],
570619
private_connector_gate_packets=[
571-
_load_json_object(path) for path in args.private_gate_packet_json
620+
_load_json_object(path, args.max_input_bytes) for path in args.private_gate_packet_json
572621
],
573622
surface_id=args.surface_id,
574623
generated_at=args.generated_at,
@@ -615,14 +664,14 @@ def handle_content_ops_command(
615664
renderer = render_content_ops_item_packet_markdown
616665
elif args.content_ops_command == "item-transition":
617666
payload = apply_content_ops_item_event(
618-
_load_json_object(args.item_json),
619-
_load_json_object(args.event_json),
667+
_load_json_object(args.item_json, args.max_input_bytes),
668+
_load_json_object(args.event_json, args.max_input_bytes),
620669
)
621670
renderer = render_content_ops_item_packet_markdown
622671
elif args.content_ops_command == "queue-status":
623672
payload = build_content_ops_queue_status_packet(
624673
items=[
625-
_load_json_object(path) for path in args.item_json
674+
_load_json_object(path, args.max_input_bytes) for path in args.item_json
626675
],
627676
queue_id=args.queue_id,
628677
generated_at=args.generated_at,
@@ -646,8 +695,8 @@ def handle_content_ops_command(
646695
renderer = render_layout_packet_markdown
647696
elif args.content_ops_command == "layout-check":
648697
payload = check_layout_packet(
649-
_load_json_object(args.plan_json),
650-
_load_json_object(args.measurement_json),
698+
_load_json_object(args.plan_json, args.max_input_bytes),
699+
_load_json_object(args.measurement_json, args.max_input_bytes),
651700
)
652701
renderer = render_layout_packet_markdown
653702
else:

‎loopx/capabilities/periodic_report/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,12 @@ The CLI streams the append-only log and applies the 4,096-row capacity limit
203203
only after goal, segment-window, and relevant-kind filtering. Malformed durable
204204
rows and an oversized relevant window fail closed.
205205

206+
Every `--*-json` input, from a file or `-` for stdin, is refused before parsing
207+
when it exceeds 16 MiB (16,777,216 bytes). Pass `--max-input-bytes N` on the
208+
subcommand to set a different positive limit for a legitimately larger or
209+
deliberately smaller request; there is no unlimited setting. The limit counts
210+
input bytes, not total process memory. Request files stay strict UTF-8.
211+
206212
### Automatic post-writeback intent
207213

208214
Automatic milestone evaluation is default-off. A project may opt the built-in

‎loopx/capabilities/periodic_report/cli.py‎

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import argparse
4+
import codecs
45
import json
56
import os
67
import sys
@@ -40,6 +41,49 @@
4041
)
4142
from ...paths import resolve_runtime_root
4243

44+
MAX_INPUT_BYTES = 16 * 1024 * 1024
45+
46+
47+
def _input_byte_limit(text: str) -> int:
48+
# An explicit, positive byte count; there is no unbounded setting.
49+
if not text.isdecimal() or int(text) < 1:
50+
raise argparse.ArgumentTypeError(f"must be a positive whole number of bytes, got {text!r}")
51+
return int(text)
52+
53+
54+
def _add_input_limit(parser: argparse.ArgumentParser) -> None:
55+
parser.add_argument(
56+
"--max-input-bytes",
57+
type=_input_byte_limit,
58+
default=MAX_INPUT_BYTES,
59+
help=(
60+
"Refuse a JSON input file or stdin larger than this many bytes before "
61+
f"parsing (default {MAX_INPUT_BYTES}). Bounds input size, not process memory."
62+
),
63+
)
64+
65+
66+
def _read_stdin_text(limit: int, label: str) -> str:
67+
# Read through the text stream so stdin keeps its decoding, but charge each
68+
# chunk by its encoded size: one character can take up to four bytes. One
69+
# incremental encoder spans all chunks, so a BOM is charged only once.
70+
encoding = getattr(sys.stdin, "encoding", None) or "utf-8"
71+
errors = getattr(sys.stdin, "errors", None) or "surrogatepass"
72+
encoder = codecs.getincrementalencoder(encoding)(errors)
73+
chunks: list[str] = []
74+
consumed = 0
75+
while consumed <= limit:
76+
chunk = sys.stdin.read(max(1, (limit + 1 - consumed) // 4))
77+
if not chunk:
78+
consumed += len(encoder.encode("", final=True))
79+
break
80+
chunks.append(chunk)
81+
consumed += len(encoder.encode(chunk))
82+
if consumed > limit:
83+
raise ValueError(f"{label} exceeds the {limit}-byte limit")
84+
return "".join(chunks)
85+
86+
4387
PrintPayload = Callable[
4488
[dict[str, object], str, Callable[[dict[str, object]], str]],
4589
None,
@@ -59,11 +103,17 @@ def __call__(
59103
ProviderCommandHandler = Callable[..., int | None]
60104

61105

62-
def _load_json_object(path_text: str) -> dict[str, Any]:
106+
def _load_json_object(path_text: str, limit: int) -> dict[str, Any]:
107+
label = "periodic-report JSON input"
63108
if path_text == "-":
64-
payload = json.loads(sys.stdin.read())
109+
text = _read_stdin_text(limit, label)
65110
else:
66-
payload = json.loads(Path(path_text).expanduser().read_text(encoding="utf-8"))
111+
with Path(path_text).expanduser().open("rb") as source:
112+
raw = source.read(limit + 1)
113+
if len(raw) > limit:
114+
raise ValueError(f"{label} exceeds the {limit}-byte limit")
115+
text = raw.decode("utf-8")
116+
payload = json.loads(text)
67117
if not isinstance(payload, dict):
68118
raise ValueError(f"{path_text} must contain a JSON object")
69119
return payload
@@ -85,6 +135,7 @@ def register_periodic_report_commands(
85135
help="Normalize one periodic_report_v0 attempt without provider effects.",
86136
)
87137
add_subcommand_format(compose)
138+
_add_input_limit(compose)
88139
compose.add_argument(
89140
"--request-json",
90141
required=True,
@@ -95,6 +146,7 @@ def register_periodic_report_commands(
95146
help="Evaluate cadence and material progress triggers without effects.",
96147
)
97148
add_subcommand_format(evaluate)
149+
_add_input_limit(evaluate)
98150
evaluate.add_argument(
99151
"--request-json",
100152
required=True,
@@ -105,6 +157,7 @@ def register_periodic_report_commands(
105157
help="Promote durable rollout events into a periodic-report trigger.",
106158
)
107159
add_subcommand_format(evaluate_runtime)
160+
_add_input_limit(evaluate_runtime)
108161
evaluate_runtime.add_argument(
109162
"--request-json",
110163
required=True,
@@ -153,6 +206,7 @@ def register_periodic_report_commands(
153206
help="Preview or apply the runtime-root machine periodic-report policy.",
154207
)
155208
add_subcommand_format(configure_machine_defaults)
209+
_add_input_limit(configure_machine_defaults)
156210
configure_machine_defaults.add_argument(
157211
"--config-json",
158212
required=True,
@@ -187,6 +241,7 @@ def register_periodic_report_commands(
187241
),
188242
)
189243
add_subcommand_format(plan_goal_delivery)
244+
_add_input_limit(plan_goal_delivery)
190245
plan_goal_delivery.add_argument(
191246
"--request-json",
192247
required=True,
@@ -202,6 +257,7 @@ def register_periodic_report_commands(
202257
help="Inspect a built-in preset or project profile without provider effects.",
203258
)
204259
add_subcommand_format(inspect_profile)
260+
_add_input_limit(inspect_profile)
205261
profile_source = inspect_profile.add_mutually_exclusive_group(required=True)
206262
profile_source.add_argument(
207263
"--profile-json",
@@ -220,6 +276,7 @@ def register_periodic_report_commands(
220276
),
221277
)
222278
add_subcommand_format(archive)
279+
_add_input_limit(archive)
223280
archive.add_argument(
224281
"--request-json",
225282
required=True,
@@ -465,22 +522,22 @@ def handle_periodic_report_command(
465522
try:
466523
if args.periodic_report_command == "archive-openviking":
467524
payload = _archive_openviking(
468-
_load_json_object(args.request_json),
525+
_load_json_object(args.request_json, args.max_input_bytes),
469526
args,
470527
)
471528
elif args.periodic_report_command == "inspect-profile":
472529
payload = (
473530
build_periodic_report_preset_activation(args.preset)
474531
if args.preset
475532
else build_periodic_report_activation(
476-
_load_json_object(args.profile_json)
533+
_load_json_object(args.profile_json, args.max_input_bytes)
477534
)
478535
)
479536
elif args.periodic_report_command == "evaluate-trigger":
480-
request = _load_json_object(args.request_json)
537+
request = _load_json_object(args.request_json, args.max_input_bytes)
481538
payload = build_periodic_report_trigger_decision(request)
482539
elif args.periodic_report_command == "evaluate-runtime-trigger":
483-
request = _load_json_object(args.request_json)
540+
request = _load_json_object(args.request_json, args.max_input_bytes)
484541
payload = build_periodic_report_runtime_trigger_decision(
485542
request,
486543
rollout_events=iter_rollout_events(
@@ -550,7 +607,7 @@ def handle_periodic_report_command(
550607
if args.periodic_report_command == "configure-machine-defaults":
551608
payload = configure_periodic_report_machine_defaults(
552609
runtime_root=runtime_root,
553-
machine_defaults=_load_json_object(args.config_json),
610+
machine_defaults=_load_json_object(args.config_json, args.max_input_bytes),
554611
execute=bool(args.execute),
555612
expected_plan_revision=args.expected_plan_revision,
556613
)
@@ -569,11 +626,11 @@ def handle_periodic_report_command(
569626
registry, runtime_root_arg, registry_path=registry_path
570627
)
571628
payload = build_goal_periodic_report_delivery_plan(
572-
_load_json_object(args.request_json),
629+
_load_json_object(args.request_json, args.max_input_bytes),
573630
machine_defaults=read_periodic_report_machine_defaults(runtime_root),
574631
)
575632
else:
576-
request = _load_json_object(args.request_json)
633+
request = _load_json_object(args.request_json, args.max_input_bytes)
577634
payload = build_periodic_report_run(request)
578635
except PeriodicReportSubscriptionConfigurationError as exc:
579636
payload = {

0 commit comments

Comments
 (0)