Skip to content

Fix/guard decoupling and detection corpus - #1

Merged
nayfly merged 3 commits into
mainfrom
fix/guard-decoupling-and-detection-corpus
Jul 26, 2026
Merged

nayfly merged 3 commits into
mainfrom
fix/guard-decoupling-and-detection-corpus

Conversation

@nayfly

@nayfly nayfly commented Jul 26, 2026

Copy link
Copy Markdown
Owner

No description provided.

Robert and others added 3 commits July 26, 2026 14:05
…orpus

Four defects found by probing the engine with real inputs.

1. Executable commands could reach `allow`. ExecutionGate and CapabilityGate
   only evaluate `intent=operate`, so the whole guard chain depended on the
   parser's seven-verb list. `dd if=/dev/zero of=/dev/sda` was classified
   `ask`, skipped both gates and was allowed outright. New `intent_escalation`
   stage re-derives operative intent from command shape (substitution, pipe to
   shell, redirect to absolute path, known binary with flags, device operands,
   sudo prefix) plus any ProceduralGuard hit. The transform is monotone
   (ask -> operate only), so it cannot itself be used to relax a request.

2. ProceduralGuard used substring matching, which both missed commands and
   fired on prose. `rm -fr /`, `rm -r -f /`, `rm -rf --no-preserve-root /` and
   whitespace variants degraded from hard_deny to escalate; block-device
   destruction was undetected; `explain what mkfs does` scored 100. Replaced
   with an anchored rule corpus: `rm` invocations are parsed (flag clustering
   in any order, long flags, --no-preserve-root) and targets classified against
   root-like system paths; device rules require a /dev/ argument. Severity is
   now tiered so `critical_tag_detected` stays reserved for irreversible
   destruction -- `rm -rf /tmp/build` lands in the confirmation band rather
   than being hard-denied. Rules remain governed by policy.yaml.

3. ActorRateLimiter retained one window per distinct actor forever, so
   rotating the actor field grew the map without limit (50k actors -> 50k
   deques). Windows now live in an LRU map with a cap and an amortized sweep.
   LRU order is a security property: an actor being actively rate-limited is
   recently used, so flooding cannot evict and thereby reset its window.

4. The semantic layer could deny on its own, making a learned model
   authoritative over refusals. Its risk is now capped (semantic_max_risk,
   default 60), validated at policy load to sit strictly below every mode's
   deny_at -- a policy granting the model deny authority is rejected. It may
   escalate to a human, never refuse. Ambiguity is also decided by absence of
   keyword evidence rather than token_count < 10, so long paraphrased inputs
   reach the layer at all.

Also: loading all-MiniLM-L6-v2 (~5s cold) sat inside the decision path, making
one-shot CLI use unusable at 5.6s per decision. With require_warm_semantic_model
the advisory layer declines on a cold model and records semantic_skipped in the
trace; `aetherya warmup` enables it for long-lived processes. CLI returns to
~0.16s.

Verification: 877 tests, 100% coverage, mypy strict and ruff clean.
pipeline_benchmark p95 0.14ms (budget 10ms), chaos_benchmark p95 1.25ms
(budget 12ms) detection 1.0, security_baseline 0 differences.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two issues reported after the guard-decoupling work.

Parser: v0.8.0 classified any mention of an operative verb as operative, so
"What does rm -rf do?" became an operative request with no declared tool. That
scores 55 at the ExecutionGate — above the operative confirm_at of 50 — so
every question about a command returned `escalate`. A narrow meta-question
frame (what does/is/are, how does X work, explain/describe/define; anchored at
the start, third person only) now keeps such inputs consultive.

The frame is disqualified by any clause separator, by an explicit tool: or
mode:operative, and does not match first-person how-to phrasing, so the pinned
v0.8.0 cases are unchanged. It is an ergonomics fix, not a security boundary:
ProceduralGuard matches raw input regardless of intent and IntentEscalation
raises the request back to operate, so "explain rm -rf /" is still hard-denied.
Covered by test_meta_question_frame_is_not_a_bypass.

pre_api_gate: the shadow_timeout scenario compared absolute subprocess wall
time against fixed budgets, measuring interpreter startup and package imports
rather than what it asserts — that the shadow adds bounded latency and that
--no-wait-shadow adds none. On a host where importing aetherya.cli alone costs
130ms the 150ms budget was unreachable regardless of engine behaviour. Budgets
now apply to overhead over a shadow-disabled baseline, and the wait path also
excludes the measured provider-SDK import. Overhead is 26ms (no-wait) and 291ms
(wait, against a configured 100ms timeout) versus 157ms and 733ms of wall time.

pre_api_gate now passes all three scenarios. 891 tests, 100% coverage, mypy
strict and ruff clean, benchmarks and security_baseline unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six items making the engine deployable rather than only correct in-process.

Adversarial sweep. 26 systematic evasions run against the new procedural
corpus; 4 got through and 5 more were downgraded from hard_deny to escalate by
quoting. Closed: r''m -rf / (quote-split command name, previously ALLOWED
outright), truncate/tee to a block device, and python -c shutil.rmtree('/').
The same evasion class also worked against the JailbreakGuard —
"ign''ore previous instructions" was undetected — so quote and escape stripping
moved into the shared normalizer, which is the property text_normalize exists
to guarantee. The corpus is pinned as a regression fixture with per-case
expectations, and cases only resolvable at execution time (variable expansion,
pipe contents, base64) are asserted to reach the confirmation band rather than
hard-deny, with the limits documented.

Parser. Parameter and target values are no longer lowercased. Values were read
from a lowercased copy, so param.path=/tmp/MyFile.TXT was audited as
/tmp/myfile.txt — a different file on any case-sensitive filesystem, meaning
the audit trail did not describe the action that was authorised.

Structured input. run_pipeline_structured() and an `action` object on
POST /v1/decide take a fully-formed ActionRequest, skipping the heuristic
parser so no part of the decision depends on inferring structure from free
text. Every other stage is unchanged: structured callers still pass the ABI
contract and IntentEscalation, so declaring intent="ask" on a destructive
payload bypasses nothing.

Distributed rate limiting. The in-process limiter meant that behind N workers
the effective limit was N x requests_per_window — the configured limit simply
was not the limit that applied. RedisActorRateLimiter shares the window via a
sorted set per actor with the drop/count/add/expire sequence in one pipeline.
Fails closed: an unreachable Redis refuses the request rather than going
silently unlimited.

Policy version contract. AETHERYA_EXPECTED_POLICY_FINGERPRINT pins the policy a
process may load; a mismatch fails at load. Two replicas on different policies
diverge on identical input and nothing surfaces that until someone diffs audit
trails — pinning turns silent divergence into a startup failure.

Semantic readiness. The advisory layer declines to run on a cold model, making
it a silent no-op wherever nothing warmed it. /health now reports
semantic_ready and degraded; the server preloads before accepting traffic, and
--require-semantic-ready fails startup instead of serving degraded.

Audit retention. AuditLogger gains fsync and an AuditSink mirror fan-out for
append-only storage. Mirrors get the byte-identical primary line so the chain
verifies the same on either copy; failures increment mirror_errors rather than
taking the decision path down.

Verification: 985 tests, 100% coverage, mypy strict and ruff clean.
pipeline_benchmark p95 0.27ms, chaos p95 1.45ms detection 1.0,
security_baseline 0 differences, pre_api_gate ok=True.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nayfly
nayfly merged commit ded2742 into main Jul 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant