@ictechgy/context-guard 0.4.7 → 0.4.9
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.
- package/CHANGELOG.md +21 -0
- package/README.ko.md +60 -22
- package/README.md +55 -21
- package/context-guard-kit/README.md +2 -2
- package/context-guard-kit/context_filter.py +212 -21
- package/context-guard-kit/context_guard_cli.py +174 -2
- package/context-guard-kit/context_pack.py +66 -21
- package/context-guard-kit/cost_guard.py +126 -59
- package/context-guard-kit/experimental_registry.py +2476 -166
- package/package.json +1 -1
- package/packaging/homebrew/context-guard.rb.template +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +1 -1
- package/plugins/context-guard/README.md +9 -2
- package/plugins/context-guard/bin/context-guard +174 -2
- package/plugins/context-guard/bin/context-guard-cost +126 -59
- package/plugins/context-guard/bin/context-guard-experiments +2476 -166
- package/plugins/context-guard/bin/context-guard-filter +212 -21
- package/plugins/context-guard/bin/context-guard-pack +66 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ictechgy/context-guard",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "ContextGuard CLI helpers for keeping AI coding agent context focused and local-first.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://github.com/ictechgy/context-guard#readme",
|
|
@@ -5,7 +5,7 @@ class ContextGuard < Formula
|
|
|
5
5
|
|
|
6
6
|
desc "Local-first context guardrails for AI coding agents"
|
|
7
7
|
homepage "https://github.com/ictechgy/context-guard"
|
|
8
|
-
url "https://github.com/ictechgy/context-guard/archive/refs/tags/v0.4.
|
|
8
|
+
url "https://github.com/ictechgy/context-guard/archive/refs/tags/v0.4.8.tar.gz"
|
|
9
9
|
sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256"
|
|
10
10
|
license "Apache-2.0"
|
|
11
11
|
|
|
@@ -113,7 +113,7 @@ brief 모드는 코딩 에이전트가 군더더기를 줄이도록 요청하되
|
|
|
113
113
|
|
|
114
114
|
ContextGuard는 모델 토큰을 줄이기 위해 작업을 외부 AI 서비스로 전송하지 않습니다. 모든 헬퍼 명령은 로컬에서 동작합니다. 로컬 RAM/디스크 보관본은 다음에 보낼 컨텍스트를 줄이는 데 도움될 수 있지만 provider prompt cache를 대체하지 않습니다. Anthropic 배포나 청구 설명 전에는 공식 prompt caching/pricing 문서를 다시 확인하세요: https://docs.anthropic.com/en/build-with-claude/prompt-caching 및 https://platform.claude.com/docs/en/about-claude/pricing.
|
|
115
115
|
|
|
116
|
-
미래 learned, self-hosted 최적화 아이디어는 [`research/experimental-token-reduction-radar.md`](https://github.com/ictechgy/context-guard/blob/main/research/experimental-token-reduction-radar.md)에 gated experiment로 기록하며, fixture-only 시작 예시는 [`docs/experimental-benchmark-fixtures.md`](https://github.com/ictechgy/context-guard/blob/main/docs/experimental-benchmark-fixtures.md)에 둡니다. learned compression은 `context-guard experiments plan learned-compression` dry-run checker만 shipped 상태이고, self-hosted
|
|
116
|
+
미래 learned, self-hosted 최적화 아이디어는 [`research/experimental-token-reduction-radar.md`](https://github.com/ictechgy/context-guard/blob/main/research/experimental-token-reduction-radar.md)에 gated experiment로 기록하며, fixture-only 시작 예시는 [`docs/experimental-benchmark-fixtures.md`](https://github.com/ictechgy/context-guard/blob/main/docs/experimental-benchmark-fixtures.md)에 둡니다. learned compression은 `context-guard experiments plan learned-compression` dry-run checker와 명시적 `context-guard experiments emit learned-compression` caller-supplied candidate emitter만 shipped 상태이고, self-hosted-metrics-ledger는 dry-run preview와 명시적 `context-guard experiments record self-hosted-metrics-ledger` local JSONL record를 제공하며, dry-run preview는 ledger 파일을 쓰지 않습니다. visual crop/OCR은 caller-supplied evidence-pack emit, context-diff는 verified-receipt caller-supplied replacement emit만 제공합니다. local proxy는 `context-guard experiments plan local-proxy` localhost-only dry-run advisory plan, design-only `context-guard experiments plan local-proxy-external-forwarding` gate, 명시적 `context-guard experiments record local-proxy-runtime-gate --ledger-jsonl ...` gate row record, one-shot `context-guard experiments serve local-proxy` loopback forwarding MVP와 successful forwarded request용 optional shifted-cost diagnostic JSONL row만 shipped 상태입니다. record는 no listener/no traffic forwarding/no DNS lookup/no external service/no API-key persistence boundary를 유지하고, serve는 literal loopback IP·`--once`·credential-free request만 허용하고 CONNECT/TLS proxying도 지원하지 않습니다. `--diagnostic-ledger-jsonl`은 successful forwarded request 뒤에만 진단 row를 쓰며 raw header/body나 hosted-savings evidence를 저장하지 않습니다. `plan local-proxy-external-forwarding`은 threat model, HTTPS allowlist, credential redaction, provider-evidence boundary를 점검하는 dry-run design gate이고 listener, DNS lookup, external service call, traffic forwarding, credential persistence, external proxy forwarding runtime, hosted savings claim을 제공하지 않습니다. learned/synthetic compressor 실행·embedding·reranker·model call·생성형 replacement, generated OCR/crop 또는 visual-token pruning, self-hosted KV/latent runtime 최적화, one-shot literal-loopback local proxy MVP를 넘어선 external/daemon/credential-bearing proxy forwarding runtime은 shipped가 아닙니다. 이 radar와 fixture는 provider가 측정한 matched-task 근거 없이 hosted API 절감을 주장하지 않습니다. Radar의 later-roadmap gate는 neural/semantic compression, trust-tiered injection-aware compression, generated visual-token reduction, broader local proxy forwarding constraint도 별도 미래 PR이 gate를 통과하기 전까지 experimental/non-shipped로 묶습니다.
|
|
117
117
|
|
|
118
118
|
교차 에이전트 규칙 스니펫은 권고 사항입니다. 대상 에이전트가 반드시 따른다고 보장할 수 없으므로, 절감 주장이 필요하면 실제 전후 동작을 직접 측정하세요.
|
|
119
119
|
|
|
@@ -119,7 +119,7 @@ These helpers reduce common sources of context bloat, but they do not guarantee
|
|
|
119
119
|
|
|
120
120
|
ContextGuard also does not send work to external AI providers to save model tokens. All helper commands run locally. Local RAM/disk receipts can reduce what you choose to send, but they do not replace a provider prompt cache. Before release or billing claims for Anthropic, recheck the official prompt-caching and pricing docs: https://docs.anthropic.com/en/build-with-claude/prompt-caching and https://platform.claude.com/docs/en/about-claude/pricing.
|
|
121
121
|
|
|
122
|
-
Future learned, multimodal, and self-hosted optimization ideas are tracked in [`research/experimental-token-reduction-radar.md`](https://github.com/ictechgy/context-guard/blob/main/research/experimental-token-reduction-radar.md), with fixture-only starters in [`docs/experimental-benchmark-fixtures.md`](https://github.com/ictechgy/context-guard/blob/main/docs/experimental-benchmark-fixtures.md). ContextGuard ships dry-run planners/checkers for learned
|
|
122
|
+
Future learned, multimodal, and self-hosted optimization ideas are tracked in [`research/experimental-token-reduction-radar.md`](https://github.com/ictechgy/context-guard/blob/main/research/experimental-token-reduction-radar.md), with fixture-only starters in [`docs/experimental-benchmark-fixtures.md`](https://github.com/ictechgy/context-guard/blob/main/docs/experimental-benchmark-fixtures.md). ContextGuard ships dry-run planners/checkers for local-proxy advisory plans and design-only external-forwarding gates, plus narrow explicit local runtimes for caller-supplied context-diff replacement payloads, caller-supplied visual crop/OCR evidence packs, caller-supplied learned-compression prose candidates, self-hosted metrics JSONL sidecar records, local-proxy runtime-gate JSONL records, one-shot `serve local-proxy` loopback forwarding, and optional shifted-cost diagnostic JSONL rows for successful forwarded requests. Learned/synthetic compressor execution beyond the caller-supplied candidate emitter, embeddings, rerankers, model calls, generated replacement text, screenshot capture, image cropping, OCR execution, image parsing, external OCR/image services, output-file evidence writes, self-hosted KV/latent runtime optimization beyond explicit local metrics recording, and external/daemon/hostname-DNS, credential-bearing, or external proxy forwarding beyond literal-loopback one-request HTTP forwarding are not shipped. That radar and the fixtures do not claim hosted API savings without provider-measured matched-task evidence. The radar's later-roadmap gates also keep neural/semantic compression, trust-tiered injection-aware compression, generated visual-token reduction, and broader local proxy forwarding constraints experimental/non-shipped until a separate future PR satisfies those gates.
|
|
123
123
|
|
|
124
124
|
## Experimental opt-ins
|
|
125
125
|
|
|
@@ -129,15 +129,22 @@ Experimental lanes are default off. The registry is project-local metadata only;
|
|
|
129
129
|
context-guard experiments list
|
|
130
130
|
context-guard experiments status --json
|
|
131
131
|
context-guard experiments plan context-diff-compaction --json < change.diff
|
|
132
|
+
context-guard experiments emit context-diff-compaction --receipt-id <artifact-id> --reexpand-command "context-guard-artifact get <artifact-id> --full" --replacement-file compact-diff.txt --json < change.diff
|
|
132
133
|
context-guard experiments plan visual-crop-ocr --json --full-evidence-receipt <id> --crop-label <label> --crop-bounds 0,0,100,100 --image-size 800,600 --missed-context-note "outside crop omitted"
|
|
134
|
+
context-guard experiments emit visual-crop-ocr --json --full-evidence-receipt <id> --crop-label <label> --crop-bounds 0,0,100,100 --image-size 800,600 --ocr-text "visible text" --ocr-confidence 0.9 --ocr-error-note "glyph may be uncertain" --missed-context-note "outside crop omitted"
|
|
133
135
|
context-guard experiments plan learned-compression --json --sanitized --trusted-source --exact-fallback-receipt <id> --reexpand-command "context-guard-artifact get <id> --full" < sanitized-prose.txt
|
|
136
|
+
context-guard experiments emit learned-compression --json --sanitized --trusted-source --exact-fallback-receipt <id> --reexpand-command "context-guard-artifact get <id> --full" --replacement-file compact-prose.txt < sanitized-prose.txt
|
|
134
137
|
context-guard experiments plan self-hosted-metrics-ledger --json --latency-ms 123.5 --peak-memory-mb 2048 --quality-score 0.98
|
|
138
|
+
context-guard experiments record self-hosted-metrics-ledger --ledger-jsonl .context-guard/self-hosted-metrics.jsonl --latency-ms 123.5 --peak-memory-mb 2048 --quality-score 0.98 --json
|
|
135
139
|
context-guard experiments plan local-proxy --json --bind-host 127.0.0.1 --target-host 127.0.0.1 --runtime-gate-ack
|
|
140
|
+
context-guard experiments plan local-proxy-external-forwarding --external-forwarding-intent --external-forwarding-design-ack --allow-host api.example.com --allow-scheme https --credential-redaction-policy strip-sensitive-headers --provider-evidence-boundary diagnostic-only-provider-measured-required --threat-model-note "Only user-owned HTTPS endpoint; sensitive headers are stripped before any future forwarding." --json
|
|
141
|
+
context-guard experiments record local-proxy-runtime-gate --ledger-jsonl .context-guard/local-proxy-gates.jsonl --bind-host 127.0.0.1 --target-host 127.0.0.1 --runtime-gate-ack --json
|
|
142
|
+
context-guard experiments serve local-proxy --bind-host 127.0.0.1 --bind-port 18080 --target-host 127.0.0.1 --target-port 18081 --runtime-gate-ack --forwarding-gate-ack --once --diagnostic-ledger-jsonl .context-guard/local-proxy-diagnostics.jsonl --json
|
|
136
143
|
context-guard experiments enable output-receipt-trim --root .
|
|
137
144
|
context-guard experiments disable output-receipt-trim --root .
|
|
138
145
|
```
|
|
139
146
|
|
|
140
|
-
Use `--config <path>` only for an explicit project-local override. Registry entries include risk, gate requirements, explicit command/flag surfaces, and claim boundaries; hosted API token/cost savings still require provider-measured matched-task evidence. The registry can discover existing explicit-flag experiments such as `context-guard-trim-output --digest ... --artifact-receipt` and `context-guard-compress --protected-policy`,
|
|
147
|
+
Use `--config <path>` only for an explicit project-local override. Registry entries include risk, gate requirements, explicit command/flag surfaces, and claim boundaries; hosted API token/cost savings still require provider-measured matched-task evidence. The registry can discover existing explicit-flag experiments such as `context-guard-trim-output --digest ... --artifact-receipt` and `context-guard-compress --protected-policy`, run dry-run advisory planners such as `context-guard experiments plan context-diff-compaction`, `context-guard experiments plan visual-crop-ocr`, `context-guard experiments plan learned-compression`, `context-guard experiments plan self-hosted-metrics-ledger`, `context-guard experiments plan local-proxy`, and design-only `context-guard experiments plan local-proxy-external-forwarding`, and run explicit local runtimes such as `context-guard experiments emit context-diff-compaction ...`, `context-guard experiments emit visual-crop-ocr ...`, `context-guard experiments emit learned-compression ...`, `context-guard experiments record self-hosted-metrics-ledger ...`, `context-guard experiments record local-proxy-runtime-gate ...`, `context-guard experiments serve local-proxy ...`, and successful-forward `context-guard experiments serve local-proxy --diagnostic-ledger-jsonl ...` diagnostics. The context-diff emit runtime only emits caller-supplied compact replacements when reviewable hunks, exact local artifact re-expand metadata whose stored content matches the input diff, and a smaller replacement are present; it does not generate semantic compression or permit hosted savings claims. The visual lane ships a dry-run planner plus an explicit local evidence-pack emitter: both use only caller-supplied full-evidence receipts, crop metadata, OCR text, confidence/error notes, and missed-context notes; screenshot capture, image cropping, OCR execution, image parsing, external OCR/image services, output-file writes, and hosted savings claims are not shipped. The learned-compression lane ships a deny-by-default dry-run policy check plus an explicit local candidate emitter for caller-supplied compact prose with verified exact fallback content: learned/synthetic compressor execution, embeddings, rerankers, model calls, subprocesses, external services, generated replacement text, and hosted savings claims are not shipped. The self-hosted metrics planner emits a dry-run ledger-compatible preview for explicit local/model-server latency, memory, quality, energy, throughput, and local-cost metrics; the dry-run preview does not write a ledger, while `context-guard experiments record self-hosted-metrics-ledger --ledger-jsonl ...` writes only local JSONL sidecars and still does not permit hosted API token/cost savings claims. The local-proxy planner emits localhost-only advisory metadata only, while `context-guard experiments record local-proxy-runtime-gate --ledger-jsonl ...` appends one local gate row only after localhost-only metadata and `--runtime-gate-ack`: it starts no listener, forwards no traffic, and performs no DNS lookup. `context-guard experiments serve local-proxy ...` is the separate forwarding MVP: it requires `--forwarding-gate-ack --once`, literal loopback bind/target IPs, no hostname DNS targets, nonzero ports, byte/time limits, and credential-free requests; it performs no external forwarding, no CONNECT/TLS proxying, no API-key persistence, and no hosted-savings claim. `--diagnostic-ledger-jsonl` writes one shifted-cost diagnostic row only after a successful forwarded request, with no raw headers/bodies and no hosted-savings evidence. `plan local-proxy-external-forwarding` emits threat-model/allowlist/redaction/provider-evidence design metadata only and still starts no listener, performs no DNS lookup, calls no external service, forwards no traffic, persists no credentials, and does not ship an external proxy forwarding runtime. `experiments enable` records intent only; it does not run those helpers, remove the need for their explicit flags, or permit replacing content without exact receipt/re-expand evidence.
|
|
141
148
|
|
|
142
149
|
Cross-agent rule snippets are advisory: the target agent may ignore them, so measure actual before/after behavior when you need a savings claim.
|
|
143
150
|
|
|
@@ -11,11 +11,17 @@ import json
|
|
|
11
11
|
import os
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
import subprocess
|
|
14
|
+
import stat
|
|
14
15
|
import sys
|
|
15
16
|
from typing import NoReturn
|
|
16
17
|
|
|
17
18
|
COMMAND_NAME = "context-guard"
|
|
18
19
|
PACKAGE_NAME = "@ictechgy/context-guard"
|
|
20
|
+
MAX_VERSION_METADATA_BYTES = 64 * 1024
|
|
21
|
+
ALLOWED_FIRST_ABSOLUTE_SYMLINKS = {
|
|
22
|
+
"tmp": Path("/private/tmp"),
|
|
23
|
+
"var": Path("/private/var"),
|
|
24
|
+
}
|
|
19
25
|
|
|
20
26
|
HELPER_SUBCOMMANDS: dict[str, tuple[str, ...]] = {
|
|
21
27
|
"setup": ("context-guard-setup",),
|
|
@@ -50,16 +56,182 @@ def _script_dir() -> Path:
|
|
|
50
56
|
|
|
51
57
|
def _candidate_roots() -> list[Path]:
|
|
52
58
|
script_dir = _script_dir()
|
|
53
|
-
roots = [script_dir.parent, script_dir.parent.parent
|
|
59
|
+
roots = [script_dir.parent, script_dir.parent.parent]
|
|
54
60
|
# When run from context-guard-kit in a checkout, the repo root is one level up.
|
|
55
61
|
if script_dir.name == "context-guard-kit":
|
|
56
62
|
roots.insert(0, script_dir.parent)
|
|
57
63
|
return list(dict.fromkeys(roots))
|
|
58
64
|
|
|
59
65
|
|
|
66
|
+
def _normalized_link_target(anchor: Path, raw_target: str) -> Path:
|
|
67
|
+
target = Path(raw_target)
|
|
68
|
+
if target.is_absolute():
|
|
69
|
+
return Path(os.path.normpath(str(target)))
|
|
70
|
+
return Path(os.path.normpath(str(anchor / target)))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _normalize_allowed_first_absolute_symlink(path: Path) -> Path:
|
|
74
|
+
if not path.is_absolute():
|
|
75
|
+
return path
|
|
76
|
+
parts = path.parts
|
|
77
|
+
if len(parts) < 2:
|
|
78
|
+
return path
|
|
79
|
+
expected = ALLOWED_FIRST_ABSOLUTE_SYMLINKS.get(parts[1])
|
|
80
|
+
if expected is None:
|
|
81
|
+
return path
|
|
82
|
+
first = Path(path.anchor) / parts[1]
|
|
83
|
+
try:
|
|
84
|
+
if first.is_symlink() and _normalized_link_target(Path(path.anchor), os.readlink(first)) == expected:
|
|
85
|
+
return expected.joinpath(*parts[2:])
|
|
86
|
+
except OSError:
|
|
87
|
+
return path
|
|
88
|
+
return path
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _metadata_no_follow_supported() -> bool:
|
|
92
|
+
return (
|
|
93
|
+
hasattr(os, "O_NOFOLLOW")
|
|
94
|
+
and os.open in getattr(os, "supports_dir_fd", set())
|
|
95
|
+
and os.stat in getattr(os, "supports_dir_fd", set())
|
|
96
|
+
and os.stat in getattr(os, "supports_follow_symlinks", set())
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _directory_open_flags(*, follow_final: bool = False) -> int:
|
|
101
|
+
flags = os.O_RDONLY
|
|
102
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
103
|
+
flags |= os.O_CLOEXEC
|
|
104
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
105
|
+
flags |= os.O_DIRECTORY
|
|
106
|
+
if not follow_final:
|
|
107
|
+
flags |= os.O_NOFOLLOW
|
|
108
|
+
return flags
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _metadata_file_open_flags() -> int:
|
|
112
|
+
flags = os.O_RDONLY | os.O_NOFOLLOW
|
|
113
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
114
|
+
flags |= os.O_CLOEXEC
|
|
115
|
+
if hasattr(os, "O_NONBLOCK"):
|
|
116
|
+
flags |= os.O_NONBLOCK
|
|
117
|
+
if hasattr(os, "O_NOCTTY"):
|
|
118
|
+
flags |= os.O_NOCTTY
|
|
119
|
+
return flags
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _leaf_name(path: Path) -> str | None:
|
|
123
|
+
name = path.name
|
|
124
|
+
if name in {"", ".", ".."}:
|
|
125
|
+
return None
|
|
126
|
+
return name
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _open_metadata_parent_no_follow(path: Path) -> int | None:
|
|
130
|
+
if not _metadata_no_follow_supported():
|
|
131
|
+
return None
|
|
132
|
+
path = _normalize_allowed_first_absolute_symlink(path)
|
|
133
|
+
try:
|
|
134
|
+
if path.is_absolute():
|
|
135
|
+
current_fd = os.open(path.anchor or os.sep, _directory_open_flags(follow_final=True))
|
|
136
|
+
parts = path.parts[1:-1]
|
|
137
|
+
else:
|
|
138
|
+
current_fd = os.open(".", _directory_open_flags(follow_final=True))
|
|
139
|
+
parts = path.parts[:-1]
|
|
140
|
+
except OSError:
|
|
141
|
+
return None
|
|
142
|
+
try:
|
|
143
|
+
for part in parts:
|
|
144
|
+
if part in {"", "."}:
|
|
145
|
+
continue
|
|
146
|
+
if part == "..":
|
|
147
|
+
return None
|
|
148
|
+
next_fd = -1
|
|
149
|
+
try:
|
|
150
|
+
next_fd = os.open(part, _directory_open_flags(), dir_fd=current_fd)
|
|
151
|
+
if not stat.S_ISDIR(os.fstat(next_fd).st_mode):
|
|
152
|
+
try:
|
|
153
|
+
os.close(next_fd)
|
|
154
|
+
except OSError:
|
|
155
|
+
pass
|
|
156
|
+
next_fd = -1
|
|
157
|
+
return None
|
|
158
|
+
except OSError:
|
|
159
|
+
if next_fd >= 0:
|
|
160
|
+
try:
|
|
161
|
+
os.close(next_fd)
|
|
162
|
+
except OSError:
|
|
163
|
+
pass
|
|
164
|
+
try:
|
|
165
|
+
os.close(current_fd)
|
|
166
|
+
except OSError:
|
|
167
|
+
pass
|
|
168
|
+
current_fd = -1
|
|
169
|
+
return None
|
|
170
|
+
try:
|
|
171
|
+
os.close(current_fd)
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
current_fd = next_fd
|
|
175
|
+
owned_fd = current_fd
|
|
176
|
+
current_fd = -1
|
|
177
|
+
return owned_fd
|
|
178
|
+
finally:
|
|
179
|
+
if current_fd >= 0:
|
|
180
|
+
try:
|
|
181
|
+
os.close(current_fd)
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _read_metadata_text(path: Path) -> str | None:
|
|
187
|
+
path = _normalize_allowed_first_absolute_symlink(path)
|
|
188
|
+
parent_fd = _open_metadata_parent_no_follow(path)
|
|
189
|
+
if parent_fd is None:
|
|
190
|
+
return None
|
|
191
|
+
fd = -1
|
|
192
|
+
data = b""
|
|
193
|
+
try:
|
|
194
|
+
leaf = _leaf_name(path)
|
|
195
|
+
if leaf is None:
|
|
196
|
+
return None
|
|
197
|
+
pre_open = os.stat(leaf, dir_fd=parent_fd, follow_symlinks=False)
|
|
198
|
+
if not stat.S_ISREG(pre_open.st_mode):
|
|
199
|
+
return None
|
|
200
|
+
if pre_open.st_size > MAX_VERSION_METADATA_BYTES:
|
|
201
|
+
return None
|
|
202
|
+
fd = os.open(leaf, _metadata_file_open_flags(), dir_fd=parent_fd)
|
|
203
|
+
opened = os.fstat(fd)
|
|
204
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
205
|
+
return None
|
|
206
|
+
if opened.st_size > MAX_VERSION_METADATA_BYTES:
|
|
207
|
+
return None
|
|
208
|
+
data = os.read(fd, MAX_VERSION_METADATA_BYTES + 1)
|
|
209
|
+
except OSError:
|
|
210
|
+
return None
|
|
211
|
+
finally:
|
|
212
|
+
if fd >= 0:
|
|
213
|
+
try:
|
|
214
|
+
os.close(fd)
|
|
215
|
+
except OSError:
|
|
216
|
+
pass
|
|
217
|
+
try:
|
|
218
|
+
os.close(parent_fd)
|
|
219
|
+
except OSError:
|
|
220
|
+
pass
|
|
221
|
+
if len(data) > MAX_VERSION_METADATA_BYTES:
|
|
222
|
+
return None
|
|
223
|
+
try:
|
|
224
|
+
return data.decode("utf-8")
|
|
225
|
+
except UnicodeDecodeError:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
60
229
|
def _load_json(path: Path) -> dict[str, object] | None:
|
|
230
|
+
text = _read_metadata_text(path)
|
|
231
|
+
if text is None:
|
|
232
|
+
return None
|
|
61
233
|
try:
|
|
62
|
-
data = json.loads(
|
|
234
|
+
data = json.loads(text)
|
|
63
235
|
except (OSError, json.JSONDecodeError):
|
|
64
236
|
return None
|
|
65
237
|
return data if isinstance(data, dict) else None
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
import argparse
|
|
12
12
|
import base64
|
|
13
13
|
import binascii
|
|
14
|
+
import errno
|
|
14
15
|
try:
|
|
15
16
|
import fcntl
|
|
16
17
|
except ImportError: # pragma: no cover - fcntl is unavailable on Windows.
|
|
@@ -49,6 +50,8 @@ DEFAULT_SAFETY_FACTOR = 1.25
|
|
|
49
50
|
DEFAULT_LARGE_SECTION_BYTES = 64_000
|
|
50
51
|
MAX_LEDGER_ROWS = 20_000
|
|
51
52
|
LEDGER_TAIL_INITIAL_BYTES = 64 * 1024
|
|
53
|
+
LEDGER_OPEN_RETRY_ATTEMPTS = 5
|
|
54
|
+
LEDGER_OPEN_RETRY_SECONDS = 0.01
|
|
52
55
|
TTL_SECONDS = {"5m": 5 * 60, "1h": 60 * 60}
|
|
53
56
|
ANTHROPIC_DOCS_URL = "https://docs.anthropic.com/en/build-with-claude/prompt-caching"
|
|
54
57
|
ANTHROPIC_PRICING_URL = "https://platform.claude.com/docs/en/about-claude/pricing"
|
|
@@ -58,6 +61,10 @@ ALLOWED_FIRST_COMPONENT_SYMLINKS = {
|
|
|
58
61
|
}
|
|
59
62
|
DIR_FD_OPEN_SUPPORTED = os.open in getattr(os, "supports_dir_fd", set())
|
|
60
63
|
NO_FOLLOW_SUPPORTED = hasattr(os, "O_NOFOLLOW")
|
|
64
|
+
DIR_FD_STAT_NOFOLLOW_SUPPORTED = (
|
|
65
|
+
os.stat in getattr(os, "supports_dir_fd", set())
|
|
66
|
+
and os.stat in getattr(os, "supports_follow_symlinks", set())
|
|
67
|
+
)
|
|
61
68
|
|
|
62
69
|
SECRET_RE = re.compile(
|
|
63
70
|
r"(?is)("
|
|
@@ -148,25 +155,68 @@ def token_proxy_obj(data: Any) -> int:
|
|
|
148
155
|
return token_proxy_text(json_bytes(data))
|
|
149
156
|
|
|
150
157
|
|
|
158
|
+
def read_bounded_regular_path(path: str | Path, *, max_bytes: int, label: str) -> tuple[str, bool]:
|
|
159
|
+
if max_bytes < 1 or max_bytes > MAX_MAX_BYTES:
|
|
160
|
+
fail(f"max bytes must be between 1 and {MAX_MAX_BYTES}")
|
|
161
|
+
p = reject_symlink_components(Path(path), label=label)
|
|
162
|
+
leaf_name = _private_leaf_name(p, label=label)
|
|
163
|
+
parent_fd = -1
|
|
164
|
+
fd = -1
|
|
165
|
+
try:
|
|
166
|
+
parent_fd = open_directory_no_follow(p.parent, label=f"{label} parent")
|
|
167
|
+
if not DIR_FD_STAT_NOFOLLOW_SUPPORTED:
|
|
168
|
+
fail(f"{label} requires dir_fd stat support for symlink-safe regular-file validation")
|
|
169
|
+
try:
|
|
170
|
+
pre_st = os.stat(leaf_name, dir_fd=parent_fd, follow_symlinks=False)
|
|
171
|
+
except OSError as exc:
|
|
172
|
+
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
173
|
+
if not stat.S_ISREG(pre_st.st_mode):
|
|
174
|
+
fail(f"{label} must be a regular file")
|
|
175
|
+
flags = _base_open_flags() | _no_follow_flag(label=label)
|
|
176
|
+
if hasattr(os, "O_NONBLOCK"):
|
|
177
|
+
flags |= os.O_NONBLOCK
|
|
178
|
+
if hasattr(os, "O_NOCTTY"):
|
|
179
|
+
flags |= os.O_NOCTTY
|
|
180
|
+
fd = os.open(leaf_name, flags, dir_fd=parent_fd)
|
|
181
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
182
|
+
fail(f"{label} must be a regular file")
|
|
183
|
+
chunks: list[bytes] = []
|
|
184
|
+
remaining = max_bytes + 1
|
|
185
|
+
while remaining > 0:
|
|
186
|
+
chunk = os.read(fd, min(64 * 1024, remaining))
|
|
187
|
+
if not chunk:
|
|
188
|
+
break
|
|
189
|
+
chunks.append(chunk)
|
|
190
|
+
remaining -= len(chunk)
|
|
191
|
+
raw = b"".join(chunks)
|
|
192
|
+
except CostGuardError:
|
|
193
|
+
raise
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
fail(f"could not read {label}: {os_error_detail(exc)}")
|
|
196
|
+
finally:
|
|
197
|
+
if fd >= 0:
|
|
198
|
+
try:
|
|
199
|
+
os.close(fd)
|
|
200
|
+
except OSError:
|
|
201
|
+
pass
|
|
202
|
+
if parent_fd >= 0:
|
|
203
|
+
try:
|
|
204
|
+
os.close(parent_fd)
|
|
205
|
+
except OSError:
|
|
206
|
+
pass
|
|
207
|
+
truncated = len(raw) > max_bytes
|
|
208
|
+
if truncated:
|
|
209
|
+
raw = raw[:max_bytes]
|
|
210
|
+
return raw.decode("utf-8", errors="replace"), truncated
|
|
211
|
+
|
|
212
|
+
|
|
151
213
|
def read_text_path(path: str, *, max_bytes: int = DEFAULT_MAX_BYTES) -> tuple[str, bool]:
|
|
152
214
|
if max_bytes < 1 or max_bytes > MAX_MAX_BYTES:
|
|
153
215
|
fail(f"max bytes must be between 1 and {MAX_MAX_BYTES}")
|
|
154
216
|
if path == "-":
|
|
155
217
|
raw = sys.stdin.buffer.read(max_bytes + 1)
|
|
156
218
|
else:
|
|
157
|
-
|
|
158
|
-
try:
|
|
159
|
-
st = p.stat()
|
|
160
|
-
except OSError as exc:
|
|
161
|
-
fail(f"could not read input file: {exc}")
|
|
162
|
-
if not stat.S_ISREG(st.st_mode):
|
|
163
|
-
fail("input path must be a regular file")
|
|
164
|
-
if st.st_size > max_bytes + 1:
|
|
165
|
-
# Read only the bounded prefix so large requests cannot exhaust memory.
|
|
166
|
-
with p.open("rb") as fh:
|
|
167
|
-
raw = fh.read(max_bytes + 1)
|
|
168
|
-
else:
|
|
169
|
-
raw = p.read_bytes()
|
|
219
|
+
return read_bounded_regular_path(path, max_bytes=max_bytes, label="input file")
|
|
170
220
|
truncated = len(raw) > max_bytes
|
|
171
221
|
if truncated:
|
|
172
222
|
raw = raw[:max_bytes]
|
|
@@ -494,20 +544,20 @@ def _base_open_flags() -> int:
|
|
|
494
544
|
return flags
|
|
495
545
|
|
|
496
546
|
|
|
497
|
-
def _no_follow_flag() -> int:
|
|
547
|
+
def _no_follow_flag(*, label: str = "private local cost storage") -> int:
|
|
498
548
|
if not NO_FOLLOW_SUPPORTED:
|
|
499
|
-
fail("
|
|
549
|
+
fail(f"{label} requires O_NOFOLLOW support")
|
|
500
550
|
return os.O_NOFOLLOW
|
|
501
551
|
|
|
502
552
|
|
|
503
|
-
def _directory_open_flags(*, follow_final: bool = False) -> int:
|
|
553
|
+
def _directory_open_flags(*, follow_final: bool = False, label: str = "private local cost storage") -> int:
|
|
504
554
|
flags = os.O_RDONLY
|
|
505
555
|
if hasattr(os, "O_CLOEXEC"):
|
|
506
556
|
flags |= os.O_CLOEXEC
|
|
507
557
|
if hasattr(os, "O_DIRECTORY"):
|
|
508
558
|
flags |= os.O_DIRECTORY
|
|
509
559
|
if not follow_final:
|
|
510
|
-
flags |= _no_follow_flag()
|
|
560
|
+
flags |= _no_follow_flag(label=label)
|
|
511
561
|
return flags
|
|
512
562
|
|
|
513
563
|
|
|
@@ -572,18 +622,18 @@ def reject_symlink_components(path: Path, *, label: str) -> Path:
|
|
|
572
622
|
return path
|
|
573
623
|
|
|
574
624
|
|
|
575
|
-
def
|
|
625
|
+
def open_directory_no_follow(path: Path, *, label: str) -> int:
|
|
576
626
|
"""Open an existing directory without following symlink path components."""
|
|
577
627
|
|
|
578
628
|
if not dir_fd_open_supported():
|
|
579
|
-
fail(f"{label} requires dir_fd support for symlink-safe
|
|
629
|
+
fail(f"{label} requires dir_fd support for symlink-safe directory traversal")
|
|
580
630
|
path = reject_symlink_components(path, label=label)
|
|
581
|
-
flags = _directory_open_flags()
|
|
631
|
+
flags = _directory_open_flags(label=label)
|
|
582
632
|
if path.is_absolute():
|
|
583
633
|
anchor = path.anchor or os.sep
|
|
584
634
|
parts = path.parts[1:]
|
|
585
635
|
try:
|
|
586
|
-
current_fd = os.open(anchor, _directory_open_flags(follow_final=True))
|
|
636
|
+
current_fd = os.open(anchor, _directory_open_flags(follow_final=True, label=label))
|
|
587
637
|
except OSError as exc:
|
|
588
638
|
fail(f"could not inspect {label}: {os_error_detail(exc)}")
|
|
589
639
|
else:
|
|
@@ -635,6 +685,12 @@ def open_private_directory(path: Path, *, label: str) -> int:
|
|
|
635
685
|
pass
|
|
636
686
|
|
|
637
687
|
|
|
688
|
+
def open_private_directory(path: Path, *, label: str) -> int:
|
|
689
|
+
"""Open an existing private-storage directory without following symlinks."""
|
|
690
|
+
|
|
691
|
+
return open_directory_no_follow(path, label=label)
|
|
692
|
+
|
|
693
|
+
|
|
638
694
|
def fsync_directory_fd(fd: int) -> None:
|
|
639
695
|
if os.name != "posix":
|
|
640
696
|
return
|
|
@@ -676,7 +732,7 @@ def open_private_regular_fd_for_read(path: Path, *, label: str) -> int:
|
|
|
676
732
|
fd = -1
|
|
677
733
|
try:
|
|
678
734
|
parent_fd = open_private_directory(path.parent, label=f"{label} parent")
|
|
679
|
-
fd = os.open(leaf_name, _base_open_flags() | _no_follow_flag(), dir_fd=parent_fd)
|
|
735
|
+
fd = os.open(leaf_name, _base_open_flags() | _no_follow_flag(label=label), dir_fd=parent_fd)
|
|
680
736
|
st = os.fstat(fd)
|
|
681
737
|
if not stat.S_ISREG(st.st_mode):
|
|
682
738
|
fail(f"{label} must be a regular file")
|
|
@@ -1138,41 +1194,47 @@ def open_private_regular_file_for_append(path: Path, *, label: str) -> int:
|
|
|
1138
1194
|
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | _no_follow_flag()
|
|
1139
1195
|
if hasattr(os, "O_CLOEXEC"):
|
|
1140
1196
|
flags |= os.O_CLOEXEC
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
try:
|
|
1144
|
-
parent_fd = open_private_directory(path.parent, label=f"{label} parent")
|
|
1145
|
-
fd = os.open(leaf_name, flags, 0o600, dir_fd=parent_fd)
|
|
1146
|
-
st = os.fstat(fd)
|
|
1147
|
-
if not stat.S_ISREG(st.st_mode):
|
|
1148
|
-
fail(f"{label} must be a regular file")
|
|
1149
|
-
try:
|
|
1150
|
-
os.fchmod(fd, 0o600)
|
|
1151
|
-
except (AttributeError, OSError):
|
|
1152
|
-
pass
|
|
1153
|
-
st = os.fstat(fd)
|
|
1154
|
-
if os.name == "posix" and stat.S_IMODE(st.st_mode) != 0o600:
|
|
1155
|
-
fail(f"could not verify {label} privacy: expected mode 0600")
|
|
1156
|
-
owned_fd = fd
|
|
1197
|
+
for attempt in range(LEDGER_OPEN_RETRY_ATTEMPTS):
|
|
1198
|
+
parent_fd = -1
|
|
1157
1199
|
fd = -1
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
if fd >= 0:
|
|
1165
|
-
# Ownership transfers to the caller only on the successful return
|
|
1166
|
-
# above. On errors, close before surfacing a deterministic message.
|
|
1167
|
-
try:
|
|
1168
|
-
os.close(fd)
|
|
1169
|
-
except OSError:
|
|
1170
|
-
pass
|
|
1171
|
-
if parent_fd >= 0:
|
|
1200
|
+
try:
|
|
1201
|
+
parent_fd = open_private_directory(path.parent, label=f"{label} parent")
|
|
1202
|
+
fd = os.open(leaf_name, flags, 0o600, dir_fd=parent_fd)
|
|
1203
|
+
st = os.fstat(fd)
|
|
1204
|
+
if not stat.S_ISREG(st.st_mode):
|
|
1205
|
+
fail(f"{label} must be a regular file")
|
|
1172
1206
|
try:
|
|
1173
|
-
os.
|
|
1174
|
-
except OSError:
|
|
1207
|
+
os.fchmod(fd, 0o600)
|
|
1208
|
+
except (AttributeError, OSError):
|
|
1175
1209
|
pass
|
|
1210
|
+
st = os.fstat(fd)
|
|
1211
|
+
if os.name == "posix" and stat.S_IMODE(st.st_mode) != 0o600:
|
|
1212
|
+
fail(f"could not verify {label} privacy: expected mode 0600")
|
|
1213
|
+
owned_fd = fd
|
|
1214
|
+
fd = -1
|
|
1215
|
+
return owned_fd
|
|
1216
|
+
except CostGuardError:
|
|
1217
|
+
raise
|
|
1218
|
+
except OSError as exc:
|
|
1219
|
+
if exc.errno == errno.ENOENT and attempt + 1 < LEDGER_OPEN_RETRY_ATTEMPTS:
|
|
1220
|
+
time.sleep(LEDGER_OPEN_RETRY_SECONDS)
|
|
1221
|
+
continue
|
|
1222
|
+
fail(f"could not open {label}: {os_error_detail(exc)}")
|
|
1223
|
+
finally:
|
|
1224
|
+
if fd >= 0:
|
|
1225
|
+
# Ownership transfers to the caller only on the successful
|
|
1226
|
+
# return above. On errors, close before surfacing a
|
|
1227
|
+
# deterministic message.
|
|
1228
|
+
try:
|
|
1229
|
+
os.close(fd)
|
|
1230
|
+
except OSError:
|
|
1231
|
+
pass
|
|
1232
|
+
if parent_fd >= 0:
|
|
1233
|
+
try:
|
|
1234
|
+
os.close(parent_fd)
|
|
1235
|
+
except OSError:
|
|
1236
|
+
pass
|
|
1237
|
+
raise AssertionError("unreachable: append retry loop exits via return or fail")
|
|
1176
1238
|
|
|
1177
1239
|
|
|
1178
1240
|
def load_ledger(store_dir: Path) -> list[dict[str, Any]]:
|
|
@@ -1280,7 +1342,7 @@ def default_pricing_profile() -> dict[str, Any]:
|
|
|
1280
1342
|
}
|
|
1281
1343
|
|
|
1282
1344
|
|
|
1283
|
-
def load_pricing_profile(raw: str | None) -> dict[str, Any]:
|
|
1345
|
+
def load_pricing_profile(raw: str | None, *, max_bytes: int = DEFAULT_MAX_BYTES) -> dict[str, Any]:
|
|
1284
1346
|
profile = default_pricing_profile()
|
|
1285
1347
|
if not raw:
|
|
1286
1348
|
return profile
|
|
@@ -1288,7 +1350,12 @@ def load_pricing_profile(raw: str | None) -> dict[str, Any]:
|
|
|
1288
1350
|
if raw.lstrip().startswith("{"):
|
|
1289
1351
|
override = json.loads(raw, parse_constant=reject_json_constant)
|
|
1290
1352
|
else:
|
|
1291
|
-
|
|
1353
|
+
text, truncated = read_bounded_regular_path(raw, max_bytes=max_bytes, label="pricing profile")
|
|
1354
|
+
if truncated:
|
|
1355
|
+
fail("pricing profile exceeded max bytes")
|
|
1356
|
+
override = json.loads(text, parse_constant=reject_json_constant)
|
|
1357
|
+
except CostGuardError:
|
|
1358
|
+
raise
|
|
1292
1359
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
1293
1360
|
fail(f"could not load pricing profile: {exc}")
|
|
1294
1361
|
if not isinstance(override, dict):
|
|
@@ -1542,7 +1609,7 @@ def annotate_cache_state(
|
|
|
1542
1609
|
def preflight_command(args: argparse.Namespace) -> int:
|
|
1543
1610
|
request_raw, _truncated = load_json_input(args.request, max_bytes=args.max_bytes)
|
|
1544
1611
|
request = require_json_object(request_raw, "request")
|
|
1545
|
-
profile = load_pricing_profile(args.pricing_profile)
|
|
1612
|
+
profile = load_pricing_profile(args.pricing_profile, max_bytes=args.max_bytes)
|
|
1546
1613
|
if args.usd_to_krw is not None:
|
|
1547
1614
|
profile["usd_to_krw"] = usd_to_krw(profile, args.usd_to_krw)
|
|
1548
1615
|
if args.budget_usd is not None:
|
|
@@ -1809,7 +1876,7 @@ def observe_command(args: argparse.Namespace) -> int:
|
|
|
1809
1876
|
usage = usage_raw
|
|
1810
1877
|
if not isinstance(usage, dict):
|
|
1811
1878
|
fail("usage must be a JSON object or an object containing a usage object")
|
|
1812
|
-
profile = load_pricing_profile(args.pricing_profile)
|
|
1879
|
+
profile = load_pricing_profile(args.pricing_profile, max_bytes=args.max_bytes)
|
|
1813
1880
|
if args.usd_to_krw is not None:
|
|
1814
1881
|
profile["usd_to_krw"] = usd_to_krw(profile, args.usd_to_krw)
|
|
1815
1882
|
model = str(args.model or (usage_raw.get("model") if isinstance(usage_raw, dict) else "") or "unknown")
|
|
@@ -2217,7 +2284,7 @@ def emit(data: dict[str, Any], *, json_mode: bool) -> None:
|
|
|
2217
2284
|
def add_common_cost_args(parser: argparse.ArgumentParser) -> None:
|
|
2218
2285
|
parser.add_argument("--pricing-profile", help="JSON string or file with input/output rates, cache multipliers, and usd_to_krw")
|
|
2219
2286
|
parser.add_argument("--usd-to-krw", type=float, help="override USD→KRW exchange rate used for estimates")
|
|
2220
|
-
parser.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES, help=f"maximum JSON input bytes (default: {DEFAULT_MAX_BYTES})")
|
|
2287
|
+
parser.add_argument("--max-bytes", type=int, default=DEFAULT_MAX_BYTES, help=f"maximum JSON input and pricing profile file bytes (default: {DEFAULT_MAX_BYTES})")
|
|
2221
2288
|
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
|
2222
2289
|
|
|
2223
2290
|
|