@ictechgy/context-guard 0.4.10 → 0.4.12

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 (32) hide show
  1. package/CHANGELOG.md +17 -1
  2. package/README.ko.md +46 -28
  3. package/README.md +42 -33
  4. package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
  5. package/docs/benchmark-workflow-examples.md +3 -0
  6. package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
  7. package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
  8. package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
  9. package/docs/experimental-benchmark-fixtures.md +24 -7
  10. package/package.json +2 -1
  11. package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
  12. package/plugins/context-guard/README.ko.md +14 -11
  13. package/plugins/context-guard/README.md +15 -14
  14. package/plugins/context-guard/bin/context-guard +48 -17
  15. package/plugins/context-guard/bin/context-guard-artifact +342 -33
  16. package/plugins/context-guard/bin/context-guard-audit +36 -5
  17. package/plugins/context-guard/bin/context-guard-bench +1675 -44
  18. package/plugins/context-guard/bin/context-guard-cache-score +347 -35
  19. package/plugins/context-guard/bin/context-guard-compress +89 -27
  20. package/plugins/context-guard/bin/context-guard-cost +7 -2
  21. package/plugins/context-guard/bin/context-guard-experiments +364 -8
  22. package/plugins/context-guard/bin/context-guard-failed-nudge +6 -2
  23. package/plugins/context-guard/bin/context-guard-filter +88 -18
  24. package/plugins/context-guard/bin/context-guard-pack +329 -19
  25. package/plugins/context-guard/bin/context-guard-read-symbol +27 -0
  26. package/plugins/context-guard/bin/context-guard-sanitize-output +245 -18
  27. package/plugins/context-guard/bin/context-guard-setup +21 -5
  28. package/plugins/context-guard/bin/context-guard-tool-prune +287 -62
  29. package/plugins/context-guard/bin/context-guard-trim-output +394 -90
  30. package/plugins/context-guard/brief/README.md +5 -5
  31. package/plugins/context-guard/lib/context_guard_command_manifest_loader.py +123 -0
  32. package/plugins/context-guard/lib/context_guard_commands.py +217 -190
@@ -2855,12 +2855,17 @@ def compile_command(args: argparse.Namespace) -> int:
2855
2855
 
2856
2856
  recommended = sorted(sections, key=lambda sec: (bool(sec["volatile"]), 0 if sec["ttl"] == "1h" else 1, -int(sec["bytes"] or 0), str(sec["id"])))
2857
2857
  findings: list[dict[str, Any]] = []
2858
+ suffix_has_one_hour_ttl = [False] * (len(sections) + 1)
2859
+ suffix_has_stable_section = [False] * (len(sections) + 1)
2860
+ for index in range(len(sections) - 1, -1, -1):
2861
+ suffix_has_one_hour_ttl[index] = suffix_has_one_hour_ttl[index + 1] or sections[index]["ttl"] == "1h"
2862
+ suffix_has_stable_section[index] = suffix_has_stable_section[index + 1] or not bool(sections[index]["volatile"])
2858
2863
  for i, sec in enumerate(sections):
2859
- if sec["ttl"] == "5m" and any(later["ttl"] == "1h" for later in sections[i + 1 :]):
2864
+ if sec["ttl"] == "5m" and suffix_has_one_hour_ttl[i + 1]:
2860
2865
  findings.append({"severity": "warn", "code": "ttl_order_violation", "section_id": sec["id"], "message": "place 1h cacheable stable sections before 5m sections"})
2861
2866
  break
2862
2867
  for i, sec in enumerate(sections):
2863
- if sec["volatile"] and any(not later["volatile"] for later in sections[i + 1 :]):
2868
+ if sec["volatile"] and suffix_has_stable_section[i + 1]:
2864
2869
  findings.append({"severity": "warn", "code": "volatile_prefix_before_stable_context", "section_id": sec["id"], "message": "move volatile context toward the tail so stable prefixes can be reused"})
2865
2870
  break
2866
2871
  if len(sections) > 4:
@@ -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
@@ -64,6 +66,7 @@ LOCAL_PROXY_FORWARD_SCHEMA_VERSION = "contextguard.experiments.local-proxy-forwa
64
66
  LOCAL_PROXY_DIAGNOSTIC_SCHEMA_VERSION = "contextguard.experiments.local-proxy-forward-diagnostic.v1"
65
67
  LOCAL_PROXY_READY_SCHEMA_VERSION = "contextguard.experiments.local-proxy-ready.v1"
66
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"
67
70
  LOCAL_PROXY_DEFAULT_BIND_HOST = "127.0.0.1"
68
71
  LOCAL_PROXY_DEFAULT_BIND_PORT = 0
69
72
  LOCAL_PROXY_DEFAULT_TARGET_HOST = "127.0.0.1"
@@ -76,6 +79,7 @@ LOCAL_PROXY_DEFAULT_MAX_RESPONSE_BYTES = 256 * 1024
76
79
  LOCAL_PROXY_MAX_FORWARD_BYTES = 2 * 1024 * 1024
77
80
  LOCAL_PROXY_DEFAULT_TIMEOUT_SECONDS = 5.0
78
81
  LOCAL_PROXY_MAX_TIMEOUT_SECONDS = 30.0
82
+ LOCAL_PROXY_RESPONSE_SANDBOX_SCOPE = "local_proxy_sanitized_response_body"
79
83
  LOCAL_PROXY_EXTERNAL_ALLOWED_SCHEMES = {"https"}
80
84
  LOCAL_PROXY_EXTERNAL_CREDENTIAL_REDACTION_POLICY = "strip-sensitive-headers"
81
85
  LOCAL_PROXY_EXTERNAL_PROVIDER_EVIDENCE_BOUNDARY = "diagnostic-only-provider-measured-required"
@@ -2646,6 +2650,9 @@ def read_local_proxy_payload(args: argparse.Namespace) -> tuple[dict[str, Any],
2646
2650
  "max_response_bytes",
2647
2651
  "timeout_seconds",
2648
2652
  "diagnostic_ledger_jsonl",
2653
+ "response_sandbox",
2654
+ "response_artifact_dir",
2655
+ "show_artifact_paths",
2649
2656
  }
2650
2657
  ignored.extend(sanitize_self_hosted_ignored_key(key) for key in envelope if key not in allowed)
2651
2658
  return dict(envelope), {
@@ -3038,8 +3045,28 @@ def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
3038
3045
  "diagnostic_ledger_jsonl",
3039
3046
  "diagnostic_ledger_jsonl",
3040
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)
3041
3066
  diagnostic_ledger_path = sanitize_local_proxy_value(diagnostic_ledger_raw) if diagnostic_ledger_raw else None
3042
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)
3043
3070
  bind_host = payload["bind"]["host"]
3044
3071
  target_host = payload["target"]["host"]
3045
3072
  bind_ip_literal = is_loopback_ip_literal(bind_host)
@@ -3070,6 +3097,7 @@ def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
3070
3097
  "listener_started": False,
3071
3098
  "traffic_forwarded": False,
3072
3099
  "stable_runtime_behavior_changed": False,
3100
+ "response_sandbox_enabled": response_sandbox,
3073
3101
  })
3074
3102
  payload["forwarding"] = dict(payload["forwarding"])
3075
3103
  payload["forwarding"].update({
@@ -3094,6 +3122,30 @@ def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
3094
3122
  "bytes_written": 0,
3095
3123
  "reason": None if diagnostic_ledger_raw else "not_requested",
3096
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
+ }
3097
3149
  payload["client_auth"] = {
3098
3150
  "required": True,
3099
3151
  "type": "nonce_header",
@@ -3104,6 +3156,8 @@ def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
3104
3156
  "nonce_forwarded_upstream": False,
3105
3157
  }
3106
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
3107
3161
  payload["forward_result"] = None
3108
3162
 
3109
3163
  blockers = list(payload["review_plan"]["readiness_blockers"])
@@ -3135,6 +3189,10 @@ def local_proxy_forward_payload(args: argparse.Namespace) -> dict[str, Any]:
3135
3189
  blockers.append("invalid_max_response_bytes")
3136
3190
  if not timeout_valid:
3137
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")
3138
3196
  blockers = list(dict.fromkeys(blockers))
3139
3197
  payload["review_plan"]["readiness_blockers"] = blockers
3140
3198
  payload["review_plan"]["next_steps"] = [
@@ -3173,6 +3231,10 @@ def local_proxy_forward_diagnostic_row(payload: dict[str, Any]) -> dict[str, Any
3173
3231
  "upstream_status": result.get("upstream_status"),
3174
3232
  "upstream_response_bytes": result.get("upstream_response_bytes", 0),
3175
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"),
3176
3238
  },
3177
3239
  "runtime_limits": payload["runtime_limits"],
3178
3240
  "network_actions": payload["network_actions"],
@@ -3186,6 +3248,7 @@ def local_proxy_forward_diagnostic_row(payload: dict[str, Any]) -> dict[str, Any
3186
3248
  "dns_lookup_attempted": False,
3187
3249
  "connect_tunneling_allowed": False,
3188
3250
  "https_mitm_allowed": False,
3251
+ "response_sandboxed": bool(result.get("response_sandboxed")),
3189
3252
  "hosted_api_token_savings_claim_allowed": False,
3190
3253
  "hosted_api_cost_savings_claim_allowed": False,
3191
3254
  },
@@ -3264,6 +3327,222 @@ def local_proxy_response_headers(headers: Any) -> list[tuple[str, str]]:
3264
3327
  return result
3265
3328
 
3266
3329
 
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
+
3267
3546
  def write_local_proxy_ready_file(path: str | None, *, bind_host: str, bind_port: int, auth_nonce: str) -> None:
3268
3547
  if not path:
3269
3548
  return
@@ -3300,6 +3579,10 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3300
3579
  max_request_bytes = int(limits["max_request_bytes"])
3301
3580
  max_response_bytes = int(limits["max_response_bytes"])
3302
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"))
3303
3586
  auth_nonce = secrets.token_urlsafe(32)
3304
3587
  server_result: dict[str, Any] = {
3305
3588
  "served_once": False,
@@ -3321,6 +3604,13 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3321
3604
  "client_auth_delivered": False,
3322
3605
  "client_auth_nonce_forwarded": False,
3323
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,
3324
3614
  }
3325
3615
 
3326
3616
  def finish_blocked(
@@ -3416,6 +3706,9 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3416
3706
  server_result.update(local_proxy_request_target_meta(self.path))
3417
3707
  if not self.authorize_request():
3418
3708
  return
3709
+ if response_sandbox_enabled and self.command != "GET":
3710
+ finish_blocked(self, 405, "response_sandbox_get_only")
3711
+ return
3419
3712
  if local_proxy_secret_like(self.path):
3420
3713
  finish_blocked(self, 400, "secret_like_request_target")
3421
3714
  return
@@ -3462,14 +3755,54 @@ def serve_local_proxy_once(payload: dict[str, Any], *, ready_file: str | None =
3462
3755
  if local_proxy_bytes_secret_like(response_body):
3463
3756
  finish_blocked(self, 502, "upstream_response_sensitive_content_blocked")
3464
3757
  return
3465
- self.send_response(response.status, response.reason)
3466
- for header_name, header_value in local_proxy_response_headers(response.headers):
3467
- self.send_header(header_name, header_value)
3468
- self.send_header("Content-Length", str(len(response_body)))
3469
- self.send_header("Connection", "close")
3470
- self.end_headers()
3471
- if self.command != "HEAD":
3472
- 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)
3473
3806
  server_result.update({
3474
3807
  "served_once": True,
3475
3808
  "forwarded": True,
@@ -3562,6 +3895,12 @@ def command_serve_local_proxy(args: argparse.Namespace) -> int:
3562
3895
  payload["network_actions"]["external_services_called"] = False
3563
3896
  payload["policy"]["listener_started"] = bool(result.get("listener_started"))
3564
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)
3565
3904
  if result["forwarded"]:
3566
3905
  payload["status"] = "served_once"
3567
3906
  elif result.get("blocked_reason") == "ready_file_write_failed":
@@ -3571,6 +3910,8 @@ def command_serve_local_proxy(args: argparse.Namespace) -> int:
3571
3910
  payload["status"] = "blocked_request"
3572
3911
  maybe_write_local_proxy_forward_diagnostic(payload)
3573
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)
3574
3915
  if args.json:
3575
3916
  emit_json(payload)
3576
3917
  else:
@@ -4348,6 +4689,21 @@ def build_parser() -> argparse.ArgumentParser:
4348
4689
  "--diagnostic-ledger-jsonl",
4349
4690
  help="Append one shifted-cost diagnostic JSONL row only after a successful loopback forwarded request.",
4350
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
+ )
4351
4707
  serve_local_proxy.add_argument("--api-key", help="Blocked/redacted API key material; never persisted or emitted raw.")
4352
4708
  serve_local_proxy.add_argument("--authorization-header", help="Blocked/redacted Authorization header; never persisted or emitted raw.")
4353
4709
  serve_local_proxy.add_argument("--persist-api-key", action="store_true", help="Declare API-key persistence intent; blocked.")
@@ -92,12 +92,16 @@ NUDGE_TEXT = (
92
92
  "prompt cache 도 매 retry 마다 재워밍됨을 의미합니다. "
93
93
  "재시도 전에 사용자에게 `/clear` 또는 `/compact focus on …` 으로 세션을 정리한 뒤 "
94
94
  "재현 명령·기대 결과·금지 사항을 더 좁혀 다시 prompt 하도록 안내하거나, "
95
- "근본적으로 다른 방향(다른 모듈 / 검증 명령 / 더 작은 재현)을 제안하세요."
95
+ "근본적으로 다른 방향(다른 모듈 / 검증 명령 / 더 작은 재현)을 제안하세요. "
96
+ "직전 출력에 artifact_receipt 또는 contextguard-artifact:<id> 핸들이 있으면, 전체 로그를 다시 붙여넣거나 "
97
+ "동일한 broad 명령을 재실행하기 전에 context-guard-artifact receipt/get/search 로 필요한 줄·패턴만 "
98
+ "정확히 rehydrate 하도록 우선 제안하세요."
96
99
  )
97
100
  STRATEGY_SWITCH_TEXT = (
98
101
  " Strategy-switch signal: the same failure direction has now repeated at least three times. "
99
102
  "Stop retrying the identical command path; summarize the invariant failure, choose a different hypothesis "
100
- "or smaller reproducer, and only rerun after changing code, inputs, or diagnostic scope."
103
+ "or smaller reproducer, rehydrate exact artifact receipt slices when available, "
104
+ "and only rerun after changing code, inputs, or diagnostic scope."
101
105
  )
102
106
 
103
107