@ictechgy/context-guard 0.4.9 → 0.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.ko.md +59 -31
  3. package/README.md +85 -36
  4. package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
  5. package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
  6. package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
  7. package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
  8. package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
  9. package/docs/benchmark-workflow-examples.md +3 -0
  10. package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
  11. package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
  12. package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
  13. package/docs/distribution.md +10 -7
  14. package/docs/experimental-benchmark-fixtures.md +30 -6
  15. package/package.json +4 -6
  16. package/packaging/homebrew/context-guard.rb.template +1 -1
  17. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  18. package/plugins/context-guard/README.ko.md +20 -14
  19. package/plugins/context-guard/README.md +26 -17
  20. package/plugins/context-guard/bin/context-guard +147 -25
  21. package/plugins/context-guard/bin/context-guard-artifact +884 -79
  22. package/plugins/context-guard/bin/context-guard-audit +33 -2
  23. package/plugins/context-guard/bin/context-guard-bench +1542 -31
  24. package/plugins/context-guard/bin/context-guard-cache-score +665 -0
  25. package/plugins/context-guard/bin/context-guard-compress +146 -1
  26. package/plugins/context-guard/bin/context-guard-cost +790 -6
  27. package/plugins/context-guard/bin/context-guard-experiments +463 -26
  28. package/plugins/context-guard/bin/context-guard-failed-nudge +9 -2
  29. package/plugins/context-guard/bin/context-guard-filter +163 -7
  30. package/plugins/context-guard/bin/context-guard-guard-read +3 -0
  31. package/plugins/context-guard/bin/context-guard-pack +892 -49
  32. package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
  33. package/plugins/context-guard/bin/context-guard-sanitize-output +76 -12
  34. package/plugins/context-guard/bin/context-guard-setup +165 -31
  35. package/plugins/context-guard/bin/context-guard-statusline +490 -283
  36. package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
  37. package/plugins/context-guard/bin/context-guard-tool-prune +480 -53
  38. package/plugins/context-guard/bin/context-guard-trim-output +288 -41
  39. package/plugins/context-guard/brief/README.md +5 -5
  40. package/plugins/context-guard/lib/context_guard_commands.py +230 -0
  41. package/plugins/context-guard/skills/setup/SKILL.md +1 -0
  42. package/context-guard-kit/README.md +0 -91
  43. package/context-guard-kit/benchmark_runner.py +0 -2401
  44. package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
  45. package/context-guard-kit/context_compress.py +0 -695
  46. package/context-guard-kit/context_escrow.py +0 -935
  47. package/context-guard-kit/context_filter.py +0 -637
  48. package/context-guard-kit/context_guard_cli.py +0 -325
  49. package/context-guard-kit/context_guard_diet.py +0 -1711
  50. package/context-guard-kit/context_pack.py +0 -2713
  51. package/context-guard-kit/cost_guard.py +0 -2349
  52. package/context-guard-kit/experimental_registry.py +0 -4348
  53. package/context-guard-kit/failed_attempt_nudge.py +0 -567
  54. package/context-guard-kit/guard_large_read.py +0 -690
  55. package/context-guard-kit/hook_secret_patterns.py +0 -43
  56. package/context-guard-kit/read_symbol.py +0 -483
  57. package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
  58. package/context-guard-kit/sanitize_output.py +0 -725
  59. package/context-guard-kit/settings.example.json +0 -67
  60. package/context-guard-kit/setup_wizard.py +0 -2515
  61. package/context-guard-kit/statusline.sh +0 -362
  62. package/context-guard-kit/statusline_merged.sh +0 -157
  63. package/context-guard-kit/tool_schema_pruner.py +0 -837
  64. package/context-guard-kit/trim_command_output.py +0 -1449
@@ -14,6 +14,8 @@ from datetime import datetime, timezone
14
14
  import http.client
15
15
  from http.server import BaseHTTPRequestHandler, HTTPServer
16
16
  import hashlib
17
+ import importlib.machinery
18
+ import importlib.util
17
19
  import ipaddress
18
20
  import json
19
21
  import math
@@ -26,6 +28,7 @@ from socketserver import TCPServer
26
28
  from pathlib import Path
27
29
  import stat
28
30
  import sys
31
+ import time
29
32
  from typing import Any, NoReturn
30
33
  import unicodedata
31
34
  from urllib.parse import urlparse
@@ -63,6 +66,7 @@ LOCAL_PROXY_FORWARD_SCHEMA_VERSION = "contextguard.experiments.local-proxy-forwa
63
66
  LOCAL_PROXY_DIAGNOSTIC_SCHEMA_VERSION = "contextguard.experiments.local-proxy-forward-diagnostic.v1"
64
67
  LOCAL_PROXY_READY_SCHEMA_VERSION = "contextguard.experiments.local-proxy-ready.v1"
65
68
  LOCAL_PROXY_EXTERNAL_DESIGN_SCHEMA_VERSION = "contextguard.experiments.local-proxy-external-forwarding-design.v1"
69
+ LOCAL_PROXY_RESPONSE_SANDBOX_SCHEMA_VERSION = "contextguard.experiments.local-proxy-response-sandbox.v1"
66
70
  LOCAL_PROXY_DEFAULT_BIND_HOST = "127.0.0.1"
67
71
  LOCAL_PROXY_DEFAULT_BIND_PORT = 0
68
72
  LOCAL_PROXY_DEFAULT_TARGET_HOST = "127.0.0.1"
@@ -75,6 +79,7 @@ LOCAL_PROXY_DEFAULT_MAX_RESPONSE_BYTES = 256 * 1024
75
79
  LOCAL_PROXY_MAX_FORWARD_BYTES = 2 * 1024 * 1024
76
80
  LOCAL_PROXY_DEFAULT_TIMEOUT_SECONDS = 5.0
77
81
  LOCAL_PROXY_MAX_TIMEOUT_SECONDS = 30.0
82
+ LOCAL_PROXY_RESPONSE_SANDBOX_SCOPE = "local_proxy_sanitized_response_body"
78
83
  LOCAL_PROXY_EXTERNAL_ALLOWED_SCHEMES = {"https"}
79
84
  LOCAL_PROXY_EXTERNAL_CREDENTIAL_REDACTION_POLICY = "strip-sensitive-headers"
80
85
  LOCAL_PROXY_EXTERNAL_PROVIDER_EVIDENCE_BOUNDARY = "diagnostic-only-provider-measured-required"
@@ -89,6 +94,7 @@ LOCAL_PROXY_SENSITIVE_HEADER_NAMES = {
89
94
  "cookie",
90
95
  "set-cookie",
91
96
  }
97
+ LOCAL_PROXY_NONCE_HEADER = "X-ContextGuard-Proxy-Nonce"
92
98
  LOCAL_PROXY_HOP_BY_HOP_HEADERS = {
93
99
  "connection",
94
100
  "keep-alive",
@@ -336,8 +342,8 @@ EXPERIMENTS: tuple[Experiment, ...] = (
336
342
  "context-guard experiments plan local-proxy",
337
343
  "context-guard experiments plan local-proxy-external-forwarding",
338
344
  "context-guard experiments record local-proxy-runtime-gate --ledger-jsonl <path>",
339
- "context-guard experiments serve local-proxy --bind-host 127.0.0.1 --bind-port <port> --target-host 127.0.0.1 --target-port <port> --runtime-gate-ack --forwarding-gate-ack --once",
340
- "context-guard experiments serve local-proxy --diagnostic-ledger-jsonl <path> ...",
345
+ "context-guard experiments serve local-proxy --bind-host 127.0.0.1 --bind-port <port> --target-host 127.0.0.1 --target-port <port> --runtime-gate-ack --forwarding-gate-ack --once --ready-file <path>",
346
+ "context-guard experiments serve local-proxy --ready-file <ready-file> --diagnostic-ledger-jsonl <path> ...",
341
347
  ),
342
348
  opt_in_flags=(
343
349
  "plan local-proxy",
@@ -356,6 +362,7 @@ EXPERIMENTS: tuple[Experiment, ...] = (
356
362
  "--max-request-bytes",
357
363
  "--max-response-bytes",
358
364
  "--diagnostic-ledger-jsonl",
365
+ "--ready-file",
359
366
  "--external-forwarding-intent",
360
367
  "--external-forwarding-design-ack",
361
368
  "--allow-host",
@@ -372,10 +379,11 @@ EXPERIMENTS: tuple[Experiment, ...] = (
372
379
  ),
373
380
  evidence_contract=(
374
381
  "Gate rows require localhost-only bind/target metadata and explicit runtime gate acknowledgement. Serve "
375
- "evidence requires loopback-only bind/target IPs, explicit forwarding acknowledgement, no credential "
376
- "forwarding or persistence, bounded bytes/timeouts, and optional diagnostic ledger rows that remain "
377
- "shifted-cost evidence only. External-forwarding design plans require threat model notes, explicit "
378
- "allowlists, credential redaction policy, and provider-evidence boundaries before any future runtime."
382
+ "evidence requires loopback-only bind/target IPs, a private ready-file nonce handoff, explicit forwarding "
383
+ "acknowledgement, no credential forwarding or persistence, bounded bytes/timeouts, and optional diagnostic "
384
+ "ledger rows that remain shifted-cost evidence only. External-forwarding design plans require threat model "
385
+ "notes, explicit allowlists, credential redaction policy, and provider-evidence boundaries before any future "
386
+ "runtime."
379
387
  ),
380
388
  ),
381
389
  )
@@ -2642,6 +2650,9 @@ def read_local_proxy_payload(args: argparse.Namespace) -> tuple[dict[str, Any],
2642
2650
  "max_response_bytes",
2643
2651
  "timeout_seconds",
2644
2652
  "diagnostic_ledger_jsonl",
2653
+ "response_sandbox",
2654
+ "response_artifact_dir",
2655
+ "show_artifact_paths",
2645
2656
  }
2646
2657
  ignored.extend(sanitize_self_hosted_ignored_key(key) for key in envelope if key not in allowed)
2647
2658
  return dict(envelope), {
@@ -2684,8 +2695,13 @@ def coalesce_local_proxy_bool(args: argparse.Namespace, payload: dict[str, Any],
2684
2695
  return parse_local_proxy_json_bool(payload.get(key))
2685
2696
 
2686
2697
 
2687
- def local_proxy_plan_payload(args: argparse.Namespace) -> dict[str, Any]:
2688
- input_payload, input_meta = read_local_proxy_payload(args)
2698
+ def local_proxy_plan_payload(
2699
+ args: argparse.Namespace,
2700
+ input_payload: dict[str, Any] | None = None,
2701
+ input_meta: dict[str, Any] | None = None,
2702
+ ) -> dict[str, Any]:
2703
+ if input_payload is None or input_meta is None:
2704
+ input_payload, input_meta = read_local_proxy_payload(args)
2689
2705
  bind_host_raw = coalesce_local_proxy_value(args, input_payload, "bind_host", "bind_host")
2690
2706
  bind_port_raw = coalesce_local_proxy_value(args, input_payload, "bind_port", "bind_port")
2691
2707
  target_host_raw = coalesce_local_proxy_value(args, input_payload, "target_host", "target_host")
@@ -3001,8 +3017,8 @@ def command_record_local_proxy_runtime_gate(args: argparse.Namespace) -> int:
3001
3017
 
3002
3018
 
3003
3019
  def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
3004
- payload = local_proxy_plan_payload(args)
3005
- input_payload, _input_meta = read_local_proxy_payload(args)
3020
+ input_payload, input_meta = read_local_proxy_payload(args)
3021
+ payload = local_proxy_plan_payload(args, input_payload=input_payload, input_meta=input_meta)
3006
3022
  forwarding_gate_ack, forwarding_gate_ack_valid = coalesce_local_proxy_bool(
3007
3023
  args,
3008
3024
  input_payload,
@@ -3029,8 +3045,28 @@ def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
3029
3045
  "diagnostic_ledger_jsonl",
3030
3046
  "diagnostic_ledger_jsonl",
3031
3047
  )
3048
+ response_sandbox, response_sandbox_valid = coalesce_local_proxy_bool(
3049
+ args,
3050
+ input_payload,
3051
+ "response_sandbox",
3052
+ "response_sandbox",
3053
+ )
3054
+ show_artifact_paths, show_artifact_paths_valid = coalesce_local_proxy_bool(
3055
+ args,
3056
+ input_payload,
3057
+ "show_artifact_paths",
3058
+ "show_artifact_paths",
3059
+ )
3060
+ response_artifact_dir_raw = coalesce_local_proxy_value(
3061
+ args,
3062
+ input_payload,
3063
+ "response_artifact_dir",
3064
+ "response_artifact_dir",
3065
+ ) or str(DEFAULT_CONTEXT_DIFF_ARTIFACT_DIR)
3032
3066
  diagnostic_ledger_path = sanitize_local_proxy_value(diagnostic_ledger_raw) if diagnostic_ledger_raw else None
3033
3067
  diagnostic_ledger_write_path = str(diagnostic_ledger_raw) if diagnostic_ledger_raw else None
3068
+ response_artifact_dir = sanitize_local_proxy_value(response_artifact_dir_raw)
3069
+ response_artifact_write_dir = str(response_artifact_dir_raw)
3034
3070
  bind_host = payload["bind"]["host"]
3035
3071
  target_host = payload["target"]["host"]
3036
3072
  bind_ip_literal = is_loopback_ip_literal(bind_host)
@@ -3061,6 +3097,7 @@ def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
3061
3097
  "listener_started": False,
3062
3098
  "traffic_forwarded": False,
3063
3099
  "stable_runtime_behavior_changed": False,
3100
+ "response_sandbox_enabled": response_sandbox,
3064
3101
  })
3065
3102
  payload["forwarding"] = dict(payload["forwarding"])
3066
3103
  payload["forwarding"].update({
@@ -3085,7 +3122,42 @@ def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
3085
3122
  "bytes_written": 0,
3086
3123
  "reason": None if diagnostic_ledger_raw else "not_requested",
3087
3124
  }
3125
+ payload["response_sandbox"] = {
3126
+ "schema_version": LOCAL_PROXY_RESPONSE_SANDBOX_SCHEMA_VERSION,
3127
+ "enabled": response_sandbox,
3128
+ "artifact_dir": response_artifact_dir,
3129
+ "artifact_dir_sha256": hashlib.sha256(response_artifact_write_dir.encode("utf-8", errors="replace")).hexdigest(),
3130
+ "show_artifact_paths": show_artifact_paths,
3131
+ "exact_rehydration_commands": (
3132
+ Path(response_artifact_write_dir).expanduser() == Path(DEFAULT_CONTEXT_DIFF_ARTIFACT_DIR)
3133
+ or show_artifact_paths
3134
+ ),
3135
+ "text_policy": "utf8_text_only",
3136
+ "stored_scope": LOCAL_PROXY_RESPONSE_SANDBOX_SCOPE,
3137
+ "downstream_envelope_only": True,
3138
+ "performed": False,
3139
+ "artifact_id": None,
3140
+ "artifact_handle": None,
3141
+ "sanitized_text_sha256": None,
3142
+ "envelope_bytes": 0,
3143
+ "claim_boundary": {
3144
+ "local_only": True,
3145
+ "stored_content_is_sanitized_utf8_text": True,
3146
+ "hosted_api_token_or_cost_savings_claim_allowed": False,
3147
+ },
3148
+ }
3149
+ payload["client_auth"] = {
3150
+ "required": True,
3151
+ "type": "nonce_header",
3152
+ "header": LOCAL_PROXY_NONCE_HEADER,
3153
+ "delivery": "ready_file",
3154
+ "ready_file_required": True,
3155
+ "nonce_in_public_output": False,
3156
+ "nonce_forwarded_upstream": False,
3157
+ }
3088
3158
  payload["_diagnostic_ledger_write_path"] = diagnostic_ledger_write_path
3159
+ payload["_response_artifact_dir_write_path"] = response_artifact_write_dir
3160
+ payload["_response_artifact_show_paths"] = show_artifact_paths
3089
3161
  payload["forward_result"] = None
3090
3162
 
3091
3163
  blockers = list(payload["review_plan"]["readiness_blockers"])
@@ -3117,6 +3189,10 @@ def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
3117
3189
  blockers.append("invalid_max_response_bytes")
3118
3190
  if not timeout_valid:
3119
3191
  blockers.append("invalid_timeout_seconds")
3192
+ if not response_sandbox_valid:
3193
+ blockers.append("invalid_response_sandbox")
3194
+ if not show_artifact_paths_valid:
3195
+ blockers.append("invalid_show_artifact_paths")
3120
3196
  blockers = list(dict.fromkeys(blockers))
3121
3197
  payload["review_plan"]["readiness_blockers"] = blockers
3122
3198
  payload["review_plan"]["next_steps"] = [
@@ -3155,6 +3231,10 @@ def local_proxy_forward_diagnostic_row(payload: dict[str, Any]) -> dict[str, Any
3155
3231
  "upstream_status": result.get("upstream_status"),
3156
3232
  "upstream_response_bytes": result.get("upstream_response_bytes", 0),
3157
3233
  "body_persisted": False,
3234
+ "response_sandboxed": bool(result.get("response_sandboxed")),
3235
+ "artifact_id": result.get("response_artifact_id"),
3236
+ "artifact_handle": result.get("response_artifact_handle"),
3237
+ "sanitized_text_sha256": result.get("sanitized_text_sha256"),
3158
3238
  },
3159
3239
  "runtime_limits": payload["runtime_limits"],
3160
3240
  "network_actions": payload["network_actions"],
@@ -3168,6 +3248,7 @@ def local_proxy_forward_diagnostic_row(payload: dict[str, Any]) -> dict[str, Any
3168
3248
  "dns_lookup_attempted": False,
3169
3249
  "connect_tunneling_allowed": False,
3170
3250
  "https_mitm_allowed": False,
3251
+ "response_sandboxed": bool(result.get("response_sandboxed")),
3171
3252
  "hosted_api_token_savings_claim_allowed": False,
3172
3253
  "hosted_api_cost_savings_claim_allowed": False,
3173
3254
  },
@@ -3210,6 +3291,12 @@ def local_proxy_has_sensitive_headers(headers: Any) -> list[str]:
3210
3291
  found: list[str] = []
3211
3292
  for name, value in headers.items():
3212
3293
  lower = str(name).lower()
3294
+ if lower == LOCAL_PROXY_NONCE_HEADER.lower():
3295
+ # The per-run proxy nonce is a local client-auth secret delivered only
3296
+ # through the 0600 ready file. It is validated before this check and is
3297
+ # never forwarded upstream; do not let random nonce bytes
3298
+ # probabilistically trip the generic secret-like header detector.
3299
+ continue
3213
3300
  if lower in LOCAL_PROXY_SENSITIVE_HEADER_NAMES:
3214
3301
  found.append(lower)
3215
3302
  elif local_proxy_secret_like(name):
@@ -3240,7 +3327,223 @@ def local_proxy_response_headers(headers: Any) -> list[tuple[str, str]]:
3240
3327
  return result
3241
3328
 
3242
3329
 
3243
- def write_local_proxy_ready_file(path: str | None, *, bind_host: str, bind_port: int) -> None:
3330
+ _LOCAL_PROXY_ARTIFACT_MODULE: Any | None = None
3331
+
3332
+
3333
+ def load_local_proxy_artifact_module() -> Any:
3334
+ """Load the artifact escrow helper from kit source or packaged plugin bin.
3335
+
3336
+ The source tree exposes ``context_escrow.py`` while the packaged plugin ships
3337
+ the same implementation as an extensionless executable named
3338
+ ``context-guard-artifact``. Load either without adding non-stdlib runtime
3339
+ dependencies to the experimental registry.
3340
+ """
3341
+ global _LOCAL_PROXY_ARTIFACT_MODULE
3342
+ if _LOCAL_PROXY_ARTIFACT_MODULE is not None:
3343
+ return _LOCAL_PROXY_ARTIFACT_MODULE
3344
+ try:
3345
+ import context_escrow as artifact_module # type: ignore[import-not-found]
3346
+ except ImportError:
3347
+ current_dir = Path(__file__).resolve().parent
3348
+ candidates = [
3349
+ current_dir / "context_escrow.py",
3350
+ current_dir / "context-guard-artifact",
3351
+ ]
3352
+ for candidate in candidates:
3353
+ if not candidate.is_file():
3354
+ continue
3355
+ loader = importlib.machinery.SourceFileLoader("_context_guard_local_proxy_artifact", str(candidate))
3356
+ spec = importlib.util.spec_from_loader("_context_guard_local_proxy_artifact", loader)
3357
+ if spec is None:
3358
+ continue
3359
+ module = importlib.util.module_from_spec(spec)
3360
+ loader.exec_module(module)
3361
+ _LOCAL_PROXY_ARTIFACT_MODULE = module
3362
+ return module
3363
+ raise RegistryError("could not load local artifact helper for response sandbox")
3364
+ _LOCAL_PROXY_ARTIFACT_MODULE = artifact_module
3365
+ return artifact_module
3366
+
3367
+
3368
+ def local_proxy_response_text_policy(body: bytes, content_type: str) -> tuple[str | None, str | None, str]:
3369
+ """Return decoded UTF-8 text for response sandbox or a block reason.
3370
+
3371
+ G003 intentionally defines exact rehydration as exact sanitized UTF-8 text
3372
+ retrieval, not original arbitrary HTTP bytes.
3373
+ """
3374
+ base_content_type = content_type.split(";", 1)[0].strip().lower()
3375
+ text_like = (
3376
+ not base_content_type
3377
+ or base_content_type.startswith("text/")
3378
+ or base_content_type in {
3379
+ "application/json",
3380
+ "application/javascript",
3381
+ "application/xml",
3382
+ "application/x-ndjson",
3383
+ "application/problem+json",
3384
+ }
3385
+ or base_content_type.endswith("+json")
3386
+ or base_content_type.endswith("+xml")
3387
+ )
3388
+ binary_like = (
3389
+ base_content_type.startswith("image/")
3390
+ or base_content_type.startswith("audio/")
3391
+ or base_content_type.startswith("video/")
3392
+ or base_content_type in {
3393
+ "application/octet-stream",
3394
+ "application/pdf",
3395
+ "application/zip",
3396
+ "application/gzip",
3397
+ }
3398
+ )
3399
+ if binary_like or not text_like:
3400
+ return None, "response_sandbox_text_required", base_content_type or "unknown"
3401
+ try:
3402
+ text = body.decode("utf-8")
3403
+ except UnicodeDecodeError:
3404
+ return None, "response_sandbox_text_required", base_content_type or "unknown"
3405
+ if any((ord(ch) < 32 and ch not in "\n\r\t") or ord(ch) == 127 for ch in text):
3406
+ return None, "response_sandbox_text_required", base_content_type or "unknown"
3407
+ return text, None, base_content_type or "none"
3408
+
3409
+
3410
+ def local_proxy_store_response_artifact(
3411
+ response_text: str,
3412
+ *,
3413
+ artifact_dir: str,
3414
+ show_artifact_paths: bool,
3415
+ upstream_status: int,
3416
+ ) -> dict[str, Any]:
3417
+ artifact = load_local_proxy_artifact_module()
3418
+ directory = artifact.normalize_allowed_first_absolute_symlink(Path(artifact_dir).expanduser())
3419
+ sanitized_text, redacted_lines = artifact.sanitize_text(response_text, show_paths=show_artifact_paths)
3420
+ content_bytes = len(sanitized_text.encode("utf-8", errors="replace"))
3421
+ content_sha = hashlib.sha256(sanitized_text.encode("utf-8", errors="replace")).hexdigest()
3422
+ command_preview = f"local-proxy response sandbox upstream_status={upstream_status}"
3423
+ id_basis = json.dumps(
3424
+ {
3425
+ "content_sha256": content_sha,
3426
+ "command_preview": command_preview,
3427
+ "scope": LOCAL_PROXY_RESPONSE_SANDBOX_SCOPE,
3428
+ },
3429
+ sort_keys=True,
3430
+ )
3431
+ artifact_id = hashlib.sha256(id_basis.encode("utf-8")).hexdigest()[:20]
3432
+ content_path, meta_path = artifact.artifact_paths(directory, artifact_id)
3433
+ total_lines = sanitized_text.count("\n") + (1 if sanitized_text and not sanitized_text.endswith("\n") else 0)
3434
+ content_type = artifact.classify_content_type(sanitized_text)
3435
+ strategy = artifact.recommended_strategy(content_type)
3436
+ metadata: dict[str, Any] = {
3437
+ "artifact_id": artifact_id,
3438
+ "created_at": int(time.time()),
3439
+ "command_preview": command_preview,
3440
+ "content_type": content_type,
3441
+ "input": {
3442
+ "bytes_read": len(response_text.encode("utf-8", errors="replace")),
3443
+ "truncated": False,
3444
+ "max_bytes": LOCAL_PROXY_MAX_FORWARD_BYTES,
3445
+ },
3446
+ "stored_output": {
3447
+ "bytes": content_bytes,
3448
+ "lines": total_lines,
3449
+ "sha256": content_sha,
3450
+ "sanitized_text_sha256": content_sha,
3451
+ "content_file": content_path.name,
3452
+ "metadata_file": meta_path.name,
3453
+ "scope": LOCAL_PROXY_RESPONSE_SANDBOX_SCOPE,
3454
+ },
3455
+ "digest": artifact.build_digest(
3456
+ sanitized_text,
3457
+ artifact_id=artifact_id,
3458
+ redacted_lines=redacted_lines,
3459
+ raw_dir=artifact_dir,
3460
+ show_paths=show_artifact_paths,
3461
+ ),
3462
+ "retrieval": {
3463
+ "strategy": strategy,
3464
+ "deterministic": True,
3465
+ "hints": artifact.build_retrieval_hints(
3466
+ artifact_id,
3467
+ sanitized_text,
3468
+ content_type=content_type,
3469
+ strategy=strategy,
3470
+ total_lines=total_lines,
3471
+ raw_dir=artifact_dir,
3472
+ show_paths=show_artifact_paths,
3473
+ ),
3474
+ },
3475
+ }
3476
+ artifact.shrink_digest_for_metadata_cap(metadata)
3477
+ artifact.write_private_text(content_path, sanitized_text)
3478
+ artifact.write_private_text(meta_path, artifact.metadata_json_text(metadata))
3479
+ return artifact.receipt_for(metadata, raw_dir=artifact_dir, show_paths=show_artifact_paths)
3480
+
3481
+
3482
+ def minimized_local_proxy_rehydration(receipt: dict[str, Any]) -> dict[str, Any]:
3483
+ sandbox = receipt.get("output_sandbox")
3484
+ rehydration = sandbox.get("rehydration") if isinstance(sandbox, dict) else None
3485
+ commands = rehydration.get("commands") if isinstance(rehydration, dict) else None
3486
+ kept_commands: list[dict[str, Any]] = []
3487
+ if isinstance(commands, list):
3488
+ for command in commands[:5]:
3489
+ if not isinstance(command, dict) or not isinstance(command.get("cli"), str):
3490
+ continue
3491
+ kept_commands.append({
3492
+ key: command[key]
3493
+ for key in ("type", "selector", "cli", "exact", "note")
3494
+ if key in command
3495
+ })
3496
+ return {
3497
+ "commands": kept_commands,
3498
+ "dir_argument": rehydration.get("dir_argument") if isinstance(rehydration, dict) else None,
3499
+ "exact_commands": rehydration.get("exact_commands") if isinstance(rehydration, dict) else None,
3500
+ }
3501
+
3502
+
3503
+ def local_proxy_response_sandbox_envelope(
3504
+ *,
3505
+ receipt: dict[str, Any],
3506
+ upstream_status: int,
3507
+ upstream_response_bytes: int,
3508
+ content_type: str,
3509
+ ) -> dict[str, Any]:
3510
+ sandbox = receipt.get("output_sandbox")
3511
+ handle = sandbox.get("handle") if isinstance(sandbox, dict) else f"contextguard-artifact:{receipt.get('artifact_id')}"
3512
+ stored = receipt.get("stored_output")
3513
+ stored_output = stored if isinstance(stored, dict) else {}
3514
+ return {
3515
+ "schema_version": LOCAL_PROXY_RESPONSE_SANDBOX_SCHEMA_VERSION,
3516
+ "status": "response_sandboxed",
3517
+ "mode": "local_proxy_response_sandbox",
3518
+ "artifact_id": receipt.get("artifact_id"),
3519
+ "artifact_handle": handle,
3520
+ "upstream": {
3521
+ "status": upstream_status,
3522
+ "response_bytes": upstream_response_bytes,
3523
+ "content_type": content_type,
3524
+ },
3525
+ "stored_output": {
3526
+ "scope": LOCAL_PROXY_RESPONSE_SANDBOX_SCOPE,
3527
+ "bytes": stored_output.get("bytes"),
3528
+ "lines": stored_output.get("lines"),
3529
+ "sanitized_text_sha256": stored_output.get("sanitized_text_sha256") or stored_output.get("sha256"),
3530
+ },
3531
+ "rehydration": minimized_local_proxy_rehydration(receipt),
3532
+ "agent_guidance": [
3533
+ "Keep this compact local proxy response envelope in context instead of the full response body.",
3534
+ "Use rehydration.commands to retrieve exact sanitized UTF-8 response slices before relying on omitted details.",
3535
+ ],
3536
+ "claim_boundary": {
3537
+ "local_only": True,
3538
+ "stored_content_is_sanitized_utf8_text": True,
3539
+ "original_http_bytes_rehydration_available": False,
3540
+ "hosted_api_token_or_cost_savings_claim_allowed": False,
3541
+ "provider_measured_matched_tasks_required_for_hosted_claims": True,
3542
+ },
3543
+ }
3544
+
3545
+
3546
+ def write_local_proxy_ready_file(path: str | None, *, bind_host: str, bind_port: int, auth_nonce: str) -> None:
3244
3547
  if not path:
3245
3548
  return
3246
3549
  ready_payload = {
@@ -3254,6 +3557,14 @@ def write_local_proxy_ready_file(path: str | None, *, bind_host: str, bind_port:
3254
3557
  "host": bind_host,
3255
3558
  "port": bind_port,
3256
3559
  },
3560
+ "client_auth": {
3561
+ "required": True,
3562
+ "type": "nonce_header",
3563
+ "header": LOCAL_PROXY_NONCE_HEADER,
3564
+ "nonce": auth_nonce,
3565
+ "forwarded_upstream": False,
3566
+ "public_output": False,
3567
+ },
3257
3568
  }
3258
3569
  data = json.dumps(ready_payload, sort_keys=True).encode("utf-8") + b"\n"
3259
3570
  write_regular_file_no_follow_exclusive(Path(path), data, label="local proxy ready file", mode=0o600)
@@ -3268,6 +3579,11 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3268
3579
  max_request_bytes = int(limits["max_request_bytes"])
3269
3580
  max_response_bytes = int(limits["max_response_bytes"])
3270
3581
  timeout_seconds = float(limits["timeout_seconds"])
3582
+ response_sandbox = payload.get("response_sandbox")
3583
+ response_sandbox_enabled = bool(response_sandbox.get("enabled")) if isinstance(response_sandbox, dict) else False
3584
+ response_artifact_dir = str(payload.get("_response_artifact_dir_write_path") or DEFAULT_CONTEXT_DIFF_ARTIFACT_DIR)
3585
+ response_show_artifact_paths = bool(payload.get("_response_artifact_show_paths"))
3586
+ auth_nonce = secrets.token_urlsafe(32)
3271
3587
  server_result: dict[str, Any] = {
3272
3588
  "served_once": False,
3273
3589
  "forwarded": False,
@@ -3283,16 +3599,37 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3283
3599
  "sensitive_headers_blocked": [],
3284
3600
  "listener_started": False,
3285
3601
  "ready_file_written": False,
3602
+ "client_auth_required": True,
3603
+ "client_auth_header": LOCAL_PROXY_NONCE_HEADER,
3604
+ "client_auth_delivered": False,
3605
+ "client_auth_nonce_forwarded": False,
3606
+ "auth_failures": 0,
3607
+ "response_sandbox_requested": response_sandbox_enabled,
3608
+ "response_sandboxed": False,
3609
+ "response_artifact_id": None,
3610
+ "response_artifact_handle": None,
3611
+ "response_envelope_bytes": 0,
3612
+ "sanitized_text_sha256": None,
3613
+ "response_text_policy": "utf8_text_only" if response_sandbox_enabled else None,
3286
3614
  }
3287
3615
 
3288
- def finish_blocked(handler: BaseHTTPRequestHandler, status_code: int, reason: str, *, sensitive: list[str] | None = None) -> None:
3289
- server_result.update({
3290
- "served_once": True,
3616
+ def finish_blocked(
3617
+ handler: BaseHTTPRequestHandler,
3618
+ status_code: int,
3619
+ reason: str,
3620
+ *,
3621
+ sensitive: list[str] | None = None,
3622
+ consume_once: bool = True,
3623
+ ) -> None:
3624
+ updates = {
3291
3625
  "forwarded": False,
3292
3626
  "blocked_reason": reason,
3293
3627
  "downstream_status": status_code,
3294
3628
  "sensitive_headers_blocked": sorted(set(sensitive or [])),
3295
- })
3629
+ }
3630
+ if consume_once:
3631
+ updates["served_once"] = True
3632
+ server_result.update(updates)
3296
3633
  body = json.dumps({"status": "blocked", "reason": reason}, sort_keys=True).encode("utf-8")
3297
3634
  handler.send_response(status_code)
3298
3635
  handler.send_header("Content-Type", "application/json")
@@ -3309,9 +3646,28 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3309
3646
  def log_message(self, format: str, *args: Any) -> None: # noqa: A002 - BaseHTTPRequestHandler API.
3310
3647
  return
3311
3648
 
3649
+ def authorize_request(self) -> bool:
3650
+ values = self.headers.get_all(LOCAL_PROXY_NONCE_HEADER, [])
3651
+ if len(values) == 0:
3652
+ server_result["auth_failures"] = int(server_result.get("auth_failures", 0)) + 1
3653
+ finish_blocked(self, 403, "missing_proxy_nonce", consume_once=False)
3654
+ return False
3655
+ if len(values) != 1:
3656
+ server_result["auth_failures"] = int(server_result.get("auth_failures", 0)) + 1
3657
+ finish_blocked(self, 403, "duplicate_proxy_nonce", consume_once=False)
3658
+ return False
3659
+ candidate = str(values[0])
3660
+ if not secrets.compare_digest(candidate, auth_nonce):
3661
+ server_result["auth_failures"] = int(server_result.get("auth_failures", 0)) + 1
3662
+ finish_blocked(self, 403, "invalid_proxy_nonce", consume_once=False)
3663
+ return False
3664
+ return True
3665
+
3312
3666
  def do_CONNECT(self) -> None:
3313
3667
  server_result["request_method"] = "CONNECT"
3314
3668
  server_result.update(local_proxy_request_target_meta(self.path))
3669
+ if not self.authorize_request():
3670
+ return
3315
3671
  finish_blocked(self, 405, "connect_tunneling_not_allowed")
3316
3672
 
3317
3673
  def do_HEAD(self) -> None:
@@ -3332,6 +3688,8 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3332
3688
  def block_method(self) -> None:
3333
3689
  server_result["request_method"] = self.command
3334
3690
  server_result.update(local_proxy_request_target_meta(self.path))
3691
+ if not self.authorize_request():
3692
+ return
3335
3693
  finish_blocked(self, 405, "method_not_allowed")
3336
3694
 
3337
3695
  def do_DELETE(self) -> None:
@@ -3346,6 +3704,11 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3346
3704
  def forward_request(self) -> None:
3347
3705
  server_result["request_method"] = self.command
3348
3706
  server_result.update(local_proxy_request_target_meta(self.path))
3707
+ if not self.authorize_request():
3708
+ return
3709
+ if response_sandbox_enabled and self.command != "GET":
3710
+ finish_blocked(self, 405, "response_sandbox_get_only")
3711
+ return
3349
3712
  if local_proxy_secret_like(self.path):
3350
3713
  finish_blocked(self, 400, "secret_like_request_target")
3351
3714
  return
@@ -3392,14 +3755,54 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3392
3755
  if local_proxy_bytes_secret_like(response_body):
3393
3756
  finish_blocked(self, 502, "upstream_response_sensitive_content_blocked")
3394
3757
  return
3395
- self.send_response(response.status, response.reason)
3396
- for header_name, header_value in local_proxy_response_headers(response.headers):
3397
- self.send_header(header_name, header_value)
3398
- self.send_header("Content-Length", str(len(response_body)))
3399
- self.send_header("Connection", "close")
3400
- self.end_headers()
3401
- if self.command != "HEAD":
3402
- self.wfile.write(response_body)
3758
+ if response_sandbox_enabled:
3759
+ content_type = str(response.headers.get("Content-Type", ""))
3760
+ response_text, blocked_reason, normalized_content_type = local_proxy_response_text_policy(response_body, content_type)
3761
+ if response_text is None:
3762
+ finish_blocked(self, 502, blocked_reason or "response_sandbox_text_required")
3763
+ return
3764
+ try:
3765
+ receipt = local_proxy_store_response_artifact(
3766
+ response_text,
3767
+ artifact_dir=response_artifact_dir,
3768
+ show_artifact_paths=response_show_artifact_paths,
3769
+ upstream_status=int(response.status),
3770
+ )
3771
+ envelope = local_proxy_response_sandbox_envelope(
3772
+ receipt=receipt,
3773
+ upstream_status=int(response.status),
3774
+ upstream_response_bytes=len(response_body),
3775
+ content_type=normalized_content_type,
3776
+ )
3777
+ envelope_body = json.dumps(envelope, ensure_ascii=False, sort_keys=True).encode("utf-8") + b"\n"
3778
+ except (OSError, RuntimeError, ValueError, RegistryError, json.JSONDecodeError) as exc:
3779
+ finish_blocked(self, 502, "response_sandbox_artifact_store_failed")
3780
+ server_result["error"] = sanitize_local_proxy_value(str(exc))
3781
+ return
3782
+ self.send_response(response.status, response.reason)
3783
+ self.send_header("Content-Type", "application/json")
3784
+ self.send_header("Content-Length", str(len(envelope_body)))
3785
+ self.send_header("Connection", "close")
3786
+ self.end_headers()
3787
+ self.wfile.write(envelope_body)
3788
+ stored = receipt.get("stored_output") if isinstance(receipt, dict) else {}
3789
+ sandbox = receipt.get("output_sandbox") if isinstance(receipt, dict) else {}
3790
+ server_result.update({
3791
+ "response_sandboxed": True,
3792
+ "response_artifact_id": receipt.get("artifact_id"),
3793
+ "response_artifact_handle": sandbox.get("handle") if isinstance(sandbox, dict) else None,
3794
+ "response_envelope_bytes": len(envelope_body),
3795
+ "sanitized_text_sha256": stored.get("sanitized_text_sha256") or stored.get("sha256") if isinstance(stored, dict) else None,
3796
+ })
3797
+ else:
3798
+ self.send_response(response.status, response.reason)
3799
+ for header_name, header_value in local_proxy_response_headers(response.headers):
3800
+ self.send_header(header_name, header_value)
3801
+ self.send_header("Content-Length", str(len(response_body)))
3802
+ self.send_header("Connection", "close")
3803
+ self.end_headers()
3804
+ if self.command != "HEAD":
3805
+ self.wfile.write(response_body)
3403
3806
  server_result.update({
3404
3807
  "served_once": True,
3405
3808
  "forwarded": True,
@@ -3435,8 +3838,9 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3435
3838
  httpd.timeout = timeout_seconds
3436
3839
  try:
3437
3840
  try:
3438
- write_local_proxy_ready_file(ready_file, bind_host=bind_host, bind_port=bind_port)
3841
+ write_local_proxy_ready_file(ready_file, bind_host=bind_host, bind_port=bind_port, auth_nonce=auth_nonce)
3439
3842
  server_result["ready_file_written"] = bool(ready_file)
3843
+ server_result["client_auth_delivered"] = bool(ready_file)
3440
3844
  server_result["listener_started"] = True
3441
3845
  except RegistryError as exc:
3442
3846
  server_result.update({
@@ -3447,8 +3851,14 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3447
3851
  "error": sanitize_local_proxy_value(str(exc)),
3448
3852
  })
3449
3853
  return server_result
3450
- httpd.handle_request()
3451
- if not server_result["served_once"]:
3854
+ deadline = time.monotonic() + timeout_seconds
3855
+ while not server_result["served_once"]:
3856
+ remaining = deadline - time.monotonic()
3857
+ if remaining <= 0:
3858
+ break
3859
+ httpd.timeout = max(0.001, min(timeout_seconds, remaining))
3860
+ httpd.handle_request()
3861
+ if not server_result["served_once"] and not server_result.get("blocked_reason"):
3452
3862
  server_result.update({
3453
3863
  "blocked_reason": "timeout_waiting_for_request",
3454
3864
  "downstream_status": None,
@@ -3461,6 +3871,10 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3461
3871
  def command_serve_local_proxy(args: argparse.Namespace) -> int:
3462
3872
  payload = local_proxy_forward_payload(args)
3463
3873
  diagnostic_ledger = payload.get("diagnostic_ledger") if isinstance(payload.get("diagnostic_ledger"), dict) else {}
3874
+ if payload["status"] == "ready_to_serve" and not args.ready_file:
3875
+ payload["status"] = "blocked_until_local_proxy_forwarding_ready"
3876
+ payload["review_plan"]["readiness_blockers"].append("missing_ready_file_for_proxy_nonce")
3877
+ diagnostic_ledger["reason"] = "not_forwarded" if diagnostic_ledger.get("write_requested") else diagnostic_ledger.get("reason")
3464
3878
  if payload["status"] == "ready_to_serve" and diagnostic_ledger.get("write_requested"):
3465
3879
  try:
3466
3880
  preflight_append_jsonl_no_follow(
@@ -3481,6 +3895,12 @@ def command_serve_local_proxy(args: argparse.Namespace) -> int:
3481
3895
  payload["network_actions"]["external_services_called"] = False
3482
3896
  payload["policy"]["listener_started"] = bool(result.get("listener_started"))
3483
3897
  payload["policy"]["traffic_forwarded"] = bool(result["forwarded"])
3898
+ if isinstance(payload.get("response_sandbox"), dict):
3899
+ payload["response_sandbox"]["performed"] = bool(result.get("response_sandboxed"))
3900
+ payload["response_sandbox"]["artifact_id"] = result.get("response_artifact_id")
3901
+ payload["response_sandbox"]["artifact_handle"] = result.get("response_artifact_handle")
3902
+ payload["response_sandbox"]["sanitized_text_sha256"] = result.get("sanitized_text_sha256")
3903
+ payload["response_sandbox"]["envelope_bytes"] = result.get("response_envelope_bytes", 0)
3484
3904
  if result["forwarded"]:
3485
3905
  payload["status"] = "served_once"
3486
3906
  elif result.get("blocked_reason") == "ready_file_write_failed":
@@ -3490,6 +3910,8 @@ def command_serve_local_proxy(args: argparse.Namespace) -> int:
3490
3910
  payload["status"] = "blocked_request"
3491
3911
  maybe_write_local_proxy_forward_diagnostic(payload)
3492
3912
  payload.pop("_diagnostic_ledger_write_path", None)
3913
+ payload.pop("_response_artifact_dir_write_path", None)
3914
+ payload.pop("_response_artifact_show_paths", None)
3493
3915
  if args.json:
3494
3916
  emit_json(payload)
3495
3917
  else:
@@ -4267,6 +4689,21 @@ def build_parser() -> argparse.ArgumentParser:
4267
4689
  "--diagnostic-ledger-jsonl",
4268
4690
  help="Append one shifted-cost diagnostic JSONL row only after a successful loopback forwarded request.",
4269
4691
  )
4692
+ serve_local_proxy.add_argument(
4693
+ "--response-sandbox",
4694
+ action="store_true",
4695
+ help="Return a compact JSON response envelope and store the sanitized upstream response body as a local artifact receipt.",
4696
+ )
4697
+ serve_local_proxy.add_argument(
4698
+ "--response-artifact-dir",
4699
+ default=None,
4700
+ help="Artifact directory for --response-sandbox receipts; defaults to .context-guard/artifacts.",
4701
+ )
4702
+ serve_local_proxy.add_argument(
4703
+ "--show-artifact-paths",
4704
+ action="store_true",
4705
+ help="Include exact local artifact paths in response-sandbox rehydration commands; otherwise custom dirs are redacted.",
4706
+ )
4270
4707
  serve_local_proxy.add_argument("--api-key", help="Blocked/redacted API key material; never persisted or emitted raw.")
4271
4708
  serve_local_proxy.add_argument("--authorization-header", help="Blocked/redacted Authorization header; never persisted or emitted raw.")
4272
4709
  serve_local_proxy.add_argument("--persist-api-key", action="store_true", help="Declare API-key persistence intent; blocked.")