@miller-tech/uap 1.185.0 → 1.186.0
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/dist/.tsbuildinfo +1 -1
- package/dist/cli/policy.d.ts.map +1 -1
- package/dist/cli/policy.js +56 -0
- package/dist/cli/policy.js.map +1 -1
- package/dist/config/settings-registry.d.ts.map +1 -1
- package/dist/config/settings-registry.js +5 -1
- package/dist/config/settings-registry.js.map +1 -1
- package/dist/integrity/enforcer-manifest.d.ts +46 -0
- package/dist/integrity/enforcer-manifest.d.ts.map +1 -0
- package/dist/integrity/enforcer-manifest.js +145 -0
- package/dist/integrity/enforcer-manifest.js.map +1 -0
- package/dist/policies/policy-tools.d.ts.map +1 -1
- package/dist/policies/policy-tools.js +9 -0
- package/dist/policies/policy-tools.js.map +1 -1
- package/dist/types/config.d.ts +93 -93
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +12 -3
- package/dist/types/config.js.map +1 -1
- package/docs/getting-started/CONFIGURATION.md +1 -1
- package/docs/reference/CONFIGURATION.md +1 -1
- package/docs/reference/CONFIGURATION_REFERENCE.md +1 -1
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/enforcement_self_protect.py +149 -24
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/templates/hooks/uap-policy-gate.sh +99 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +52 -9
- package/tools/agents/tests/test_gate_failclosed_indirection.py +383 -0
- package/tools/agents/tests/test_gate_integrity.py +180 -0
- package/tools/agents/tests/test_models_context_window.py +84 -0
|
@@ -26,6 +26,7 @@ from pathlib import Path
|
|
|
26
26
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
27
27
|
from _common import ( # noqa: E402
|
|
28
28
|
emit, parse_cli, repo_root, REVIEW_ARTIFACT_DIR, REVIEW_WAIVER_DIR,
|
|
29
|
+
hands_text_to_shell,
|
|
29
30
|
)
|
|
30
31
|
|
|
31
32
|
EDIT_OPS = {"Edit", "Write", "MultiEdit", "edit", "write", "multiedit"}
|
|
@@ -128,56 +129,180 @@ _QUOTES = str.maketrans("", "", "\"'")
|
|
|
128
129
|
|
|
129
130
|
|
|
130
131
|
def _mentions_protected(text: str) -> bool:
|
|
131
|
-
"""True when
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
# Word-ish boundary so `.uap` matches `.uap`, `.uap/x` and `.uap.bak`
|
|
147
|
-
# but not an unrelated longer name like `.uapkeep`.
|
|
148
|
-
for m in re.finditer(re.escape(t), low):
|
|
149
|
-
tail = low[m.end():m.end() + 1]
|
|
150
|
-
if tail in ("", "/", ".", " ", '"', "'", "*"):
|
|
132
|
+
"""True when any UNIT of `text` names a protected path.
|
|
133
|
+
|
|
134
|
+
Judged unit by unit, not over the whole blob. The exemption used to be
|
|
135
|
+
evaluated across the entire text, so one `policies/waivers` anywhere made
|
|
136
|
+
everything else invisible — appending a single innocuous line to a deletion
|
|
137
|
+
list defeated the check completely (found by the review, reproduced).
|
|
138
|
+
"""
|
|
139
|
+
for unit in text.translate(_QUOTES).lower().replace("./", "/").split():
|
|
140
|
+
if any(ex in unit for ex in PROTECTED_EXEMPT):
|
|
141
|
+
continue
|
|
142
|
+
# The .uap DIRECTORY ITSELF: `rm -rf .uap` destroys the manifest, while
|
|
143
|
+
# `echo 0 > .uap/verify-cadence` names a deeper path and is ordinary
|
|
144
|
+
# tooling. Bare directory protected; a file under it is not.
|
|
145
|
+
for m in re.finditer(re.escape(".uap"), unit):
|
|
146
|
+
if unit[m.end():m.end() + 1] in ("", " ", '"', "'", "*"):
|
|
151
147
|
return True
|
|
148
|
+
for target in PROTECTED_TARGETS:
|
|
149
|
+
t = target.lower()
|
|
150
|
+
for m in re.finditer(re.escape(t), unit):
|
|
151
|
+
if unit[m.end():m.end() + 1] in ("", "/", ".", " ", '"', "'", "*"):
|
|
152
|
+
return True
|
|
152
153
|
return False
|
|
153
154
|
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
|
|
156
|
+
# An argument list is a list of paths; it is never a megabyte. The old 1 MiB cap
|
|
157
|
+
# also SKIPPED anything larger, so padding a list file past the cap was a
|
|
158
|
+
# one-line bypass. Read a bounded prefix instead of skipping.
|
|
159
|
+
_MAX_SRC_BYTES = 1 << 16
|
|
160
|
+
_MAX_SOURCES = 32
|
|
161
|
+
# Commands whose arguments are literal paths we can read now.
|
|
162
|
+
_FILE_READERS = ("cat", "head", "tail", "sort", "uniq", "cut", "tr", "nl", "rev")
|
|
163
|
+
_LITERAL_EMITTERS = ("echo", "printf")
|
|
164
|
+
# Wrappers that hand their remaining words back to a shell as a command.
|
|
165
|
+
_WRAPPERS = ("eval", "env", "exec", "source", ".")
|
|
166
|
+
_SHELL_C = re.compile(
|
|
167
|
+
r"(?:^|[\s;|&(])(?:ba|z|k|da|a)?sh\s+(?:-\w+\s+)*-\w*c\s+(['\"])(.*?)\1", re.S)
|
|
168
|
+
_REDIRECT = re.compile(r">>?")
|
|
169
|
+
_SUBST_FILE = re.compile(r"\$\(\s*(?:cat\s+)?<?\s*([^\s)]+)[^)]*\)|`\s*cat\s+([^`]+)`")
|
|
170
|
+
_ARGFILE = re.compile(r"--arg-file=([^\s;|&]+)|(?:^|\s)-a\s+([^\s;|&]+)")
|
|
171
|
+
_STDIN_REDIR = re.compile(r"<\s*([^\s;|&<>]+)")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _read_source(tok: str) -> str:
|
|
175
|
+
"""A bounded prefix of `tok` if it is a readable regular file, else ""."""
|
|
176
|
+
tok = (tok or "").strip("\"'`$()").rstrip(";|&")
|
|
177
|
+
if not tok or tok.startswith("-"):
|
|
178
|
+
return ""
|
|
179
|
+
try:
|
|
180
|
+
p = Path(tok)
|
|
181
|
+
if not p.is_file(): # excludes FIFOs and devices: no blocking read
|
|
182
|
+
return ""
|
|
183
|
+
with p.open(errors="replace") as fh:
|
|
184
|
+
return fh.read(_MAX_SRC_BYTES)
|
|
185
|
+
except (OSError, ValueError):
|
|
186
|
+
return ""
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolved_arguments(command: str) -> list[str]:
|
|
190
|
+
"""Text that will actually REACH a command as arguments.
|
|
191
|
+
|
|
192
|
+
Only sources knowable right now: a `< file` redirect, xargs --arg-file/-a,
|
|
193
|
+
$(cat f)/`cat f`, and an upstream pipe stage that is a literal emitter
|
|
194
|
+
(echo/printf) or a file reader (cat/head/tail/...).
|
|
195
|
+
|
|
196
|
+
Deliberately NOT resolved: `grep … | xargs sed`. grep's output is unknown
|
|
197
|
+
here, and inferring it from the pattern text is exactly what made ordinary
|
|
198
|
+
refactors unrunnable. Unknowable means allow — the same call made for a path
|
|
199
|
+
held in a shell variable.
|
|
200
|
+
"""
|
|
201
|
+
out: list[str] = []
|
|
202
|
+
|
|
203
|
+
def add(text: str) -> None:
|
|
204
|
+
if text and len(out) < _MAX_SOURCES:
|
|
205
|
+
out.append(text)
|
|
206
|
+
|
|
207
|
+
for m in _STDIN_REDIR.finditer(command):
|
|
208
|
+
add(_read_source(m.group(1)))
|
|
209
|
+
for m in _ARGFILE.finditer(command):
|
|
210
|
+
add(_read_source(m.group(1) or m.group(2)))
|
|
211
|
+
for m in _SUBST_FILE.finditer(command):
|
|
212
|
+
add(_read_source(m.group(1) or m.group(2)))
|
|
213
|
+
|
|
214
|
+
stages = [s.strip() for s in command.split("|") if s.strip()]
|
|
215
|
+
for stage in stages[:-1]: # producers only
|
|
216
|
+
toks = [t for t in stage.split() if not _ENV_ASSIGN.match(t)]
|
|
217
|
+
if not toks:
|
|
218
|
+
continue
|
|
219
|
+
verb = toks[0].rsplit("/", 1)[-1].lower()
|
|
220
|
+
if verb in _LITERAL_EMITTERS:
|
|
221
|
+
add(" ".join(toks[1:]))
|
|
222
|
+
elif verb in _FILE_READERS:
|
|
223
|
+
for t in toks[1:]:
|
|
224
|
+
if not t.startswith("-"):
|
|
225
|
+
add(_read_source(t))
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _inner_commands(command: str) -> list[str]:
|
|
230
|
+
"""Command strings this command hands back to a shell to execute."""
|
|
231
|
+
out = [m.group(2) for m in _SHELL_C.finditer(command or "")]
|
|
232
|
+
for segment in _SEGMENT_SPLIT.split(command or ""):
|
|
233
|
+
toks = [t for t in segment.split() if not _ENV_ASSIGN.match(t)]
|
|
234
|
+
if toks and toks[0].rsplit("/", 1)[-1].lower() in _WRAPPERS:
|
|
235
|
+
rest = " ".join(toks[1:]).strip().strip("\"'")
|
|
236
|
+
if rest:
|
|
237
|
+
out.append(rest)
|
|
238
|
+
return out
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _destructive_intent(command: str) -> bool:
|
|
242
|
+
"""A destructive verb or a redirect appears somewhere in `command`."""
|
|
243
|
+
toks = {t.rsplit("/", 1)[-1].lower().strip("\"'") for t in command.split()}
|
|
244
|
+
return bool(toks & set(DESTRUCTIVE_VERBS)) or bool(_REDIRECT.search(command))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _direct_destructive(command: str) -> bool:
|
|
248
|
+
"""Destructive op naming a protected path, judged per command segment."""
|
|
249
|
+
cd_into_protected = False
|
|
157
250
|
for segment in _SEGMENT_SPLIT.split(command or ""):
|
|
158
251
|
seg = segment.strip()
|
|
159
252
|
if not seg:
|
|
160
253
|
continue
|
|
161
254
|
# A redirect writes just as destructively as `rm`; the target may be
|
|
162
255
|
# quoted, ./-prefixed, or fd-numbered (`1> .uap/x`).
|
|
163
|
-
for m in
|
|
256
|
+
for m in _REDIRECT.finditer(seg):
|
|
164
257
|
if _mentions_protected(seg[m.end():]):
|
|
165
258
|
return True
|
|
166
259
|
tokens = [t for t in seg.split() if not _ENV_ASSIGN.match(t)]
|
|
167
260
|
if not tokens:
|
|
168
261
|
continue
|
|
169
262
|
verb = tokens[0].rsplit("/", 1)[-1].lower()
|
|
263
|
+
# `cd .policy-tools && rm -f _common.py` put the protected path in one
|
|
264
|
+
# segment and the verb in another, so neither segment looked dangerous.
|
|
265
|
+
if verb == "cd":
|
|
266
|
+
cd_into_protected = _mentions_protected(" ".join(tokens[1:]))
|
|
267
|
+
continue
|
|
170
268
|
if verb == "git" and len(tokens) > 1:
|
|
171
269
|
verb = f"git {tokens[1].lower()}"
|
|
172
270
|
if verb not in ("git clean", "git checkout"):
|
|
173
271
|
continue
|
|
174
272
|
elif verb not in DESTRUCTIVE_VERBS:
|
|
175
273
|
continue
|
|
176
|
-
if _mentions_protected(" ".join(tokens[1:])):
|
|
274
|
+
if cd_into_protected or _mentions_protected(" ".join(tokens[1:])):
|
|
177
275
|
return True
|
|
178
276
|
return False
|
|
179
277
|
|
|
180
278
|
|
|
279
|
+
def _bash_destructive(command: str, _depth: int = 0) -> bool:
|
|
280
|
+
"""Destructive op against the protected surface, however the target arrives.
|
|
281
|
+
|
|
282
|
+
Three ways a target reaches a verb, all of them observed:
|
|
283
|
+
1. on the command line -> _direct_destructive
|
|
284
|
+
2. through a shell wrapper -> _inner_commands (bash -c, eval, env)
|
|
285
|
+
3. as resolved arguments -> _resolved_arguments (xargs, $(cat))
|
|
286
|
+
|
|
287
|
+
HONEST LIMIT: shell state this process cannot see still wins. `P=.policy-
|
|
288
|
+
tools; rm $P/x` expands inside the shell, and no scan of command TEXT can
|
|
289
|
+
resolve a VALUE. Refusing every destructive command containing a variable
|
|
290
|
+
would block ordinary work for no real gain, so the residual is accepted and
|
|
291
|
+
covered by the gate's fail-closed and the _common.py self-heal.
|
|
292
|
+
"""
|
|
293
|
+
if not command:
|
|
294
|
+
return False
|
|
295
|
+
if _direct_destructive(command):
|
|
296
|
+
return True
|
|
297
|
+
if _depth < 2: # bounded: `bash -c "bash -c ..."`
|
|
298
|
+
for inner in _inner_commands(command):
|
|
299
|
+
if _bash_destructive(inner, _depth + 1):
|
|
300
|
+
return True
|
|
301
|
+
if (hands_text_to_shell(command) or "$(" in command or "`" in command) \
|
|
302
|
+
and _destructive_intent(command):
|
|
303
|
+
return any(_mentions_protected(src) for src in _resolved_arguments(command))
|
|
304
|
+
return False
|
|
305
|
+
|
|
181
306
|
OVERRIDE = os.environ.get("UAP_SELF_PROTECT_OFF") == "1"
|
|
182
307
|
|
|
183
308
|
|
|
Binary file
|
|
@@ -117,8 +117,56 @@ markers = ("/.policy-tools/", "/src/policies/", "/policies/", "/.uap.json",
|
|
|
117
117
|
"uap-reactor-prompt.sh", "pre-tool-use")
|
|
118
118
|
target = a.get("file_path") or a.get("path") or a.get("target") or ""
|
|
119
119
|
cmd = a.get("command") or ""
|
|
120
|
+
# Scan the COMMAND as well as file_path. Only file_path was checked, so for
|
|
121
|
+
# every Bash call SEC_SENSITIVE was 0 unless the command set a bypass var --
|
|
122
|
+
# leaving the fail-closed branch below blind to the entire shell surface. A
|
|
123
|
+
# shell deletion under .policy-tools/ then fell through to fail-OPEN the moment
|
|
124
|
+
# the enforcer could not run. Observed live: deleting .policy-tools/_common.py
|
|
125
|
+
# broke every enforcer at import, and the next `>> .uap/evidence/...` was
|
|
126
|
+
# allowed. Widening this only ever TIGHTENS: SEC_SENSITIVE gates the fail-
|
|
127
|
+
# closed path and the fastpath skip, never an allow.
|
|
128
|
+
#
|
|
129
|
+
# Each token gets a leading "/" for the same reason the target does: the markers
|
|
130
|
+
# are slash-anchored ("/.policy-tools/"), so a bare relative path in a command
|
|
131
|
+
# ("rm .policy-tools/x") would not match without it. Concatenating the raw
|
|
132
|
+
# command silently missed exactly the deletions this fix is about -- caught by
|
|
133
|
+
# measuring old-vs-new, not by reading the diff.
|
|
120
134
|
low = ("/" + str(target)).lower()
|
|
121
135
|
hit = any(m in low for m in markers)
|
|
136
|
+
# The command side needs its own pass. Markers are slash-terminated
|
|
137
|
+
# ("/.policy-tools/"), so a plain substring test missed the DIRECTORY forms --
|
|
138
|
+
# `rm -rf .policy-tools` scored 0, i.e. the single most destructive command
|
|
139
|
+
# against the surface did not arm the fail-closed net. Quoted paths missed too.
|
|
140
|
+
# Match on a path-segment boundary instead, per token, quotes stripped.
|
|
141
|
+
if not hit and cmd:
|
|
142
|
+
words = str(cmd).lower().replace("./", "/").split()
|
|
143
|
+
lead = ""
|
|
144
|
+
for w in words:
|
|
145
|
+
if "=" in w and not w.startswith("/"):
|
|
146
|
+
continue
|
|
147
|
+
lead = w.rsplit("/", 1)[-1]
|
|
148
|
+
break
|
|
149
|
+
# A read-only command cannot weaken anything, and arming fail-closed for it
|
|
150
|
+
# turns `cat .uap.json` into a hard block on any checkout where self-protect
|
|
151
|
+
# is not attached -- a state this repo has actually been in.
|
|
152
|
+
readonly = ("cat", "ls", "grep", "rg", "head", "tail", "wc", "jq", "less",
|
|
153
|
+
"stat", "file", "which", "wc")
|
|
154
|
+
if lead not in readonly:
|
|
155
|
+
for w in words:
|
|
156
|
+
u = "/" + w.strip("\"" + chr(39) + "").lstrip("/")
|
|
157
|
+
for mk in markers:
|
|
158
|
+
base = mk.rstrip("/")
|
|
159
|
+
start = u.find(base)
|
|
160
|
+
while start != -1:
|
|
161
|
+
tail = u[start + len(base):start + len(base) + 1]
|
|
162
|
+
if tail in ("", "/", ".", "*"):
|
|
163
|
+
hit = True
|
|
164
|
+
break
|
|
165
|
+
start = u.find(base, start + 1)
|
|
166
|
+
if hit:
|
|
167
|
+
break
|
|
168
|
+
if hit:
|
|
169
|
+
break
|
|
122
170
|
bypass = re.search(
|
|
123
171
|
r"UAP_DELIVER_BYPASS\s*=\s*[\x27\"]?1|UAP_ENFORCE_DELIVERY\s*=\s*[\x27\"]?(advisory|off|0|false|no)"
|
|
124
172
|
r"|UAP_SELF_PROTECT_OFF\s*=\s*[\x27\"]?1|UAP_NO_WORKTREE\s*=\s*[\x27\"]?1|UAP_WORKDIR_SCOPE_OFF\s*=\s*[\x27\"]?1|UAP_USER_VALIDATION\s*=\s*[\x27\"]?0",
|
|
@@ -225,6 +273,57 @@ record_execution() {
|
|
|
225
273
|
AND (SELECT COUNT(*) FROM policy_executions) > 2000;" 2>/dev/null || true
|
|
226
274
|
}
|
|
227
275
|
|
|
276
|
+
# INTEGRITY: the gate runs COPIES in .policy-tools/. Verify them against the
|
|
277
|
+
# manifest written at materialization and restore anything changed or missing,
|
|
278
|
+
# BEFORE any enforcer runs. This is the durable control: a text scan over shell
|
|
279
|
+
# commands cannot stop `python3 -c` from rewriting an enforcer (that is allowed
|
|
280
|
+
# by design), but it does not need to if the surface repairs itself on the next
|
|
281
|
+
# call. Covers the two observed failures — a stale copy leaving a merged fix
|
|
282
|
+
# inert, and deleting _common.py to kill all 29 enforcers at import.
|
|
283
|
+
#
|
|
284
|
+
# Cost is one hash pass over ~30 small files. `sha256sum` on Linux, `shasum` on
|
|
285
|
+
# macOS; with neither, verification is skipped rather than blocking work.
|
|
286
|
+
_PT="$MAIN_ROOT/.policy-tools"
|
|
287
|
+
if [[ -f "$_PT/.integrity.sha256" ]]; then
|
|
288
|
+
_SUM=""
|
|
289
|
+
command -v sha256sum >/dev/null 2>&1 && _SUM="sha256sum"
|
|
290
|
+
[[ -z "$_SUM" ]] && command -v shasum >/dev/null 2>&1 && _SUM="shasum -a 256"
|
|
291
|
+
if [[ -n "$_SUM" ]]; then
|
|
292
|
+
# `|| true` is load-bearing: sha256sum exits non-zero when a check fails,
|
|
293
|
+
# and under `set -euo pipefail` that status propagates out of the command
|
|
294
|
+
# substitution and kills the gate — turning a repairable drift into a dead
|
|
295
|
+
# hook. Match only the FAILED lines: they cover BOTH a changed file
|
|
296
|
+
# ("x.py: FAILED") and a missing one ("x.py: FAILED open or read"), while
|
|
297
|
+
# sha256sum's own stderr ("sha256sum: x.py: No such file...") would
|
|
298
|
+
# otherwise be captured with its prefix and restore a file called
|
|
299
|
+
# "sha256sum: x.py".
|
|
300
|
+
_BAD="$( cd "$_PT" && { $_SUM --quiet -c .integrity.sha256 2>/dev/null \
|
|
301
|
+
| sed -n 's/^\(.*\): FAILED.*$/\1/p'; } || true )"
|
|
302
|
+
if [[ -n "$_BAD" ]]; then
|
|
303
|
+
_SRC=""
|
|
304
|
+
[[ -f "$_PT/.integrity.source" ]] && _SRC="$(cat "$_PT/.integrity.source" 2>/dev/null)"
|
|
305
|
+
[[ -d "$_SRC" ]] || _SRC="$MAIN_ROOT/src/policies/enforcers"
|
|
306
|
+
while IFS= read -r _f; do
|
|
307
|
+
[[ -z "$_f" ]] && continue
|
|
308
|
+
# copies are <uuid>_<tool>.py; sources are <tool>.py
|
|
309
|
+
_base="${_f#*_}"; [[ "$_f" == "_common.py" ]] && _base="_common.py"
|
|
310
|
+
[[ -f "$_SRC/$_base" ]] && cp "$_SRC/$_base" "$_PT/$_f" 2>/dev/null || true
|
|
311
|
+
done <<< "$_BAD"
|
|
312
|
+
printf '%s\t%s\n' "$(date +%s)" "restored: $(echo "$_BAD" | tr '\n' ' ')" \
|
|
313
|
+
>> "$MAIN_ROOT/.uap/evidence/integrity.log" 2>/dev/null || true
|
|
314
|
+
fi
|
|
315
|
+
fi
|
|
316
|
+
fi
|
|
317
|
+
# Helper fallback for surfaces that predate the manifest.
|
|
318
|
+
if [[ ! -f "$_PT/_common.py" \
|
|
319
|
+
&& -f "$MAIN_ROOT/src/policies/enforcers/_common.py" ]]; then
|
|
320
|
+
cp "$MAIN_ROOT/src/policies/enforcers/_common.py" \
|
|
321
|
+
"$_PT/_common.py" 2>/dev/null || true
|
|
322
|
+
fi
|
|
323
|
+
if [[ ! -f "$MAIN_ROOT/.policy-tools/_common.py" && -d "$MAIN_ROOT/.policy-tools" ]]; then
|
|
324
|
+
[[ "$SEC_SENSITIVE" == "1" ]] && fail_closed "enforcer helper _common.py missing"
|
|
325
|
+
fi
|
|
326
|
+
|
|
228
327
|
# Did the self-protect enforcer actually run and make a decision this call?
|
|
229
328
|
sec_enforcer_ran=0
|
|
230
329
|
|
|
Binary file
|
|
@@ -12650,6 +12650,57 @@ def _parse_anthropic_sse_to_message(raw: bytes) -> dict | None:
|
|
|
12650
12650
|
}
|
|
12651
12651
|
|
|
12652
12652
|
|
|
12653
|
+
ADVERTISED_MODEL_IDS = (
|
|
12654
|
+
"claude-haiku-4-5-20251001",
|
|
12655
|
+
"claude-sonnet-4-6",
|
|
12656
|
+
"claude-sonnet-5-20250514",
|
|
12657
|
+
"claude-fable-5",
|
|
12658
|
+
"qwen36-35b-a3b-iq4xs",
|
|
12659
|
+
)
|
|
12660
|
+
|
|
12661
|
+
# Keys OpenAI-compatible clients probe for a model's context window. There is no
|
|
12662
|
+
# standard, so emit the common spellings rather than betting on one: hermes reads
|
|
12663
|
+
# context_length / context_window / max_context_length / max_model_len / n_ctx
|
|
12664
|
+
# (agent/model_metadata.py:_CONTEXT_LENGTH_KEYS), LiteLLM and vLLM prefer
|
|
12665
|
+
# max_model_len, LM Studio uses max_context_length. They are all the same number.
|
|
12666
|
+
_CONTEXT_WINDOW_KEYS = (
|
|
12667
|
+
"context_length",
|
|
12668
|
+
"context_window",
|
|
12669
|
+
"max_context_length",
|
|
12670
|
+
"max_model_len",
|
|
12671
|
+
"n_ctx",
|
|
12672
|
+
)
|
|
12673
|
+
|
|
12674
|
+
|
|
12675
|
+
def _model_entry(model_id: str) -> dict:
|
|
12676
|
+
"""One /v1/models row, carrying the context window when we know it.
|
|
12677
|
+
|
|
12678
|
+
Advertising this is not cosmetic. A client that cannot discover the window
|
|
12679
|
+
cannot size its own history to it, so it grows unbounded and the FIRST thing
|
|
12680
|
+
that notices is this proxy — which can then only prune blind, after the
|
|
12681
|
+
prompt is already built.
|
|
12682
|
+
|
|
12683
|
+
Live, 2026-08-04: hermes has a context compressor and probes for exactly
|
|
12684
|
+
these keys. We advertised bare {"id", "object"} rows, its model cache held no
|
|
12685
|
+
entry for our model, so the compressor never engaged. It sent 470 messages /
|
|
12686
|
+
219,957 tokens against a 130,048 window (169%), and the proxy CRITICAL PRUNEd
|
|
12687
|
+
290 of them to fit — 61 such events in 18 hours. Raising the window from
|
|
12688
|
+
86,784 to 130,048 had not helped, because the growth was never sized to the
|
|
12689
|
+
window in the first place.
|
|
12690
|
+
|
|
12691
|
+
Only advertised for models this proxy serves LOCALLY. A model that
|
|
12692
|
+
round-trips to api.anthropic.com has its own (much larger) window, and
|
|
12693
|
+
stamping the local llama.cpp figure on it would make clients truncate
|
|
12694
|
+
needlessly — a worse bug than the one being fixed, so when in doubt emit
|
|
12695
|
+
nothing and leave the client on its own defaults.
|
|
12696
|
+
"""
|
|
12697
|
+
entry = {"id": model_id, "object": "model"}
|
|
12698
|
+
if PROXY_CONTEXT_WINDOW > 0 and not _should_passthrough_model(model_id):
|
|
12699
|
+
for key in _CONTEXT_WINDOW_KEYS:
|
|
12700
|
+
entry[key] = PROXY_CONTEXT_WINDOW
|
|
12701
|
+
return entry
|
|
12702
|
+
|
|
12703
|
+
|
|
12653
12704
|
@app.get("/v1/models")
|
|
12654
12705
|
async def models():
|
|
12655
12706
|
"""Return available model list.
|
|
@@ -12666,15 +12717,7 @@ async def models():
|
|
|
12666
12717
|
ANTHROPIC_PASSTHROUGH_MODELS=__local_only__ is set, all IDs (including
|
|
12667
12718
|
the Claude ones below) are served by the local llama.cpp backend.
|
|
12668
12719
|
"""
|
|
12669
|
-
return {
|
|
12670
|
-
"data": [
|
|
12671
|
-
{"id": "claude-haiku-4-5-20251001", "object": "model"},
|
|
12672
|
-
{"id": "claude-sonnet-4-6", "object": "model"},
|
|
12673
|
-
{"id": "claude-sonnet-5-20250514", "object": "model"},
|
|
12674
|
-
{"id": "claude-fable-5", "object": "model"},
|
|
12675
|
-
{"id": "qwen36-35b-a3b-iq4xs", "object": "model"},
|
|
12676
|
-
]
|
|
12677
|
-
}
|
|
12720
|
+
return {"data": [_model_entry(mid) for mid in ADVERTISED_MODEL_IDS]}
|
|
12678
12721
|
|
|
12679
12722
|
|
|
12680
12723
|
@app.get("/health")
|