…l manifest URL loading (#3101)
* feat(acs): LLM annotator system prompt from file or pinned URL
The bundled LLM annotator preset previously read its system prompt only
from an inline `system_prompt` (or `prompt`) field. Add two more sources
that resolve at dispatch time and fail closed on any read or fetch error.
- `system_prompt_file`: a manifest relative path rewritten to absolute in
`Manifest::resolve_relative_paths` (mirroring the rego `bundle` path
rule) and read by the dispatcher at evaluation time.
- `system_prompt_url`: a pinned `{url, sha256|integrity}` object fetched
over the existing extends fetch path and trust gate (HTTPS only, hash
pin required, reusing `HttpExtendsFetcher` and `verify_extends_hash`).
Unlike extends, an unpinned prompt URL is rejected.
`Manifest::validate` enforces that at most one prompt source is set and
that a `system_prompt_url` is HTTPS and pinned. Validation runs on raw
fields so it covers both file based loading and `from_native`
construction (the runtime constructor calls `validate`).
Updates SPECIFICATION.md section 10 and the manifest JSON schema, and
adds unit tests for validation, the pinned fetch trust gate (mock
fetcher), the file read path, and fail closed behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* feat(acs): rego policy bundle from pinned URL
The bundled OPA dispatcher previously evaluated rego only from a local
`bundle` path. Add a `bundle_url` that lets a rego policy reference a
remote bundle pinned by `sha256` or `integrity`.
- `RegoPolicyConfig.bundle_url` is a `{url, sha256|integrity}` object,
mutually exclusive with `bundle`. `validate_policy_definition` rejects
declaring both, an unpinned URL, and a non HTTPS URL, reusing the
shared `validate_pinned_https_url` trust gate.
- At dispatch time `OpaRegoRunner` fetches the bundle over the extends
fetch path (HTTPS only, hash verified), writes it to a fresh private
temp directory, passes the local path to `opa eval --bundle`, and
removes the temp directory when evaluation finishes. A fetch error,
size breach, or hash mismatch fails closed before opa runs. URLs are
never shelled into opa directly.
The fetched body inherits the URL extends byte cap. Updates
SPECIFICATION.md section 12.1 and the manifest JSON schema, and adds
validation tests plus a temp bundle lifecycle test.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* test(acs): sync generator packaged manifest schema with spec
The generator ships a copy of spec/schema/manifest.schema.json and
test_packaged_schemas_match_canonical_spec_schemas asserts they are
byte-identical. Propagate the system_prompt_file/url and bundle_url
additions into the packaged copy.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* fix(acs): close prompt-source bypass via annotation binding; tighten bundle errors
Deep review (repro-gated) found a validation gap: validate_annotator_prompt_sources
only checked the annotator declaration, but AnnotatorInvocation::from_annotation
merges the intervention point's annotation binding over the declaration at
dispatch. A binding could set an inline `prompt` (or system_prompt_file) that
silently overrode a pinned `system_prompt_url` on the declaration, defeating the
pin requirement.
- Refactor the prompt-source check into validate_prompt_source_fields and run it
on the effective merged (declaration + binding) field set per opted-in
annotation, so more than one source fails closed with manifest_invalid.
Regression tests cover the binding-override-pinned-url case and the still-valid
single-binding-source case.
- opa.rs: a dispatch-time `bundle_url` fetch/hash/non-https failure now fails
closed as `policy_invocation_failed` rather than inheriting the extends path's
`manifest_invalid`; a size breach keeps `resource_limit_exceeded`. This labels
a runtime remote-fetch failure correctly for audit.
- Spec: drop the inaccurate "private" claim for the bundle temp dir (it is a
dedicated dir removed after evaluation, not 0700), and state that a
`system_prompt_file`, like a rego `bundle`, is not confined to the manifest
directory. Document that the at-most-one-source rule counts the merged binding.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* feat(core): load top-level manifest from a pinned HTTPS URL
Add Manifest::from_url / from_url_with_limits, which fetch the top level
manifest from an HTTPS URL through the existing URL extends trust gate
(https-only, no ambient credentials, bounded body size, sha256/integrity
verify). Unlike an extends entry the pin is mandatory because the top
level manifest is the root of trust, so an unpinned remote root fails
closed. A URL sourced manifest resolves its own extends against the URL
and never reaches the local filesystem.
Document the behaviour in SPECIFICATION.md section 2.3 and note that
filesystem-relative fields are not rebased for URL manifests.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* feat(sdk): expose from_url across FFI, Rust, Python, and Node bindings
Thread the pinned-URL manifest loader through every binding that already
exposes from_path: the C FFI (acs_builder_from_url), the Rust host
(AgentControl::from_url), the Python PyO3 native runtime plus the
NativeRuntimeClient and AgentControl wrappers, and the Node napi factory
plus its TypeScript facade. Each is a thin pass-through to
Manifest::from_url and requires url + sha256.
Add fail-closed regression tests at the Rust host and Python layers
covering non-https rejection and the mandatory pin. Core fetch/verify
paths are covered by the MockFetcher tests added with the core change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* refactor(acs): make the from_url manifest pin optional
Align top-level from_url with the URL extends trust model, where an
unpinned URL is trusted because the host chose it. The sha256 pin is now
optional across every layer (Manifest::from_url takes Option<&str>;
Python sha256=None, Node sha256?, FFI accepts a null sha256). An empty or
whitespace pin normalizes to no pin. When a pin is supplied it is still
verified and a mismatch fails closed; HTTPS-only is still enforced with
or without a pin.
Update SPECIFICATION.md section 2.3 and the core/Rust-host/Python tests
accordingly (missing-pin is now an allowed happy path).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* docs(acs): add a real Azure AI Foundry Agents integration example
Add examples/real_packages/foundry_agents.py: a genuine, non-mocked
reference showing how a production user governs Foundry tool calls with
ACS. It builds real azure-ai-agents FunctionTool definitions and backs the
policy with a live Azure OpenAI LLM judge (no canned verdicts), gated on
real credentials via _common.require_azure.
It demonstrates both integration styles for the same governed seam: the
short path (control.protect_tool) and the long path (explicit
evaluate_intervention_point with an allow/deny/escalate/transform switch),
and points at from_path / from_url manifest loading and system_prompt_file
/ system_prompt_url for production. A retry-on-transient helper keeps the
live judge from flaking the run while still honoring real denies.
Wire azure-ai-agents>=1.1,<2 into the realpkg-tests extra and add a README
for the real-package examples directory.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* ci(deps): allowlist azure-ai-agents in dependency-confusion scan
azure-ai-agents is the real Microsoft Azure AI Foundry Agents SDK on
PyPI, added to the realpkg-tests extra for the foundry_agents example.
Register it (both hyphen and underscore forms) so the strict
dependency-confusion scan recognizes it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* docs(acs): make foundry example retry helpers explicit-return only
Address the github-code-quality bot finding: the retry helpers mixed an
explicit return with an implicit fall-through return None. Restructure
both govern() and the short-path call() as a retry loop plus an explicit
final attempt so every path returns or raises explicitly.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* fix(core): harden from_url - reject local file fields, fail closed on blank pin
Deep-review findings on the from_url loader:
- Security (reproduced): a URL sourced manifest could reference local files
via a rego bundle, an annotator system_prompt_file, a cedar path, or an
adapter data path. Those are not rebased for a URL manifest, so they
resolved against the process working directory at dispatch and a remote
(optionally unpinned) manifest could read a local file and exfiltrate it
through a dispatcher. Add Manifest::reject_filesystem_path_fields, called
from load_url, so every such field now fails closed. The spec section 2.3
no longer overclaims and section 1.1 drops the stale 'pinned' wording.
- A supplied but blank sha256 silently became unpinned, unlike URL extends.
Stop swallowing it so a present blank pin fails closed; only None is unpinned.
- Restore trust_root on every load_url path by taking it only around
load_location_with_body, not across the fetch/verify early returns.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* test(acs): cover from_url at the FFI, Node, and Python binding layers
Add fail-closed binding tests that thread the optional sha256 pin: an FFI
acs_builder_from_url roundtrip (NULL and supplied pin), a Node
AgentControl.fromUrl case, and a Python malformed-pin test that fails closed
before any fetch. Closes the deep-review gap where only the core loader
exercised from_url.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* docs(acs): fail closed in the foundry example judge policy
Deep-review (reproduced): the example IntentJudgePolicy allowed on any label
other than 'destructive', so an unexpected judge label executed a destructive
tool. Fail closed: allow only an explicit 'safe' verdict; deny destructive,
unknown, or missing labels. The un-judged post-tool seam still allows.
Also make the docs honest: the example gates tool input not output, the judge
sees untrusted text and is subject to prompt injection (defense in depth),
guard the illustrative TRANSFORM branch, and drop em dashes from the README.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* fix(edu-k12): correct three regex bugs in the K-12 starter pack
Apply the same three regex corrections as #3115 (by @imran-siddique) so this
PR's docker-compose-test, which runs the full suite against the merge with
main, passes the OWASP ASI edu/K-12 tests added in #3107. Without this the
role-promotion, parental-consent, and violence-content rules do not match
their own test inputs.
Mirrors PR #3115; resolves identically on merge.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* fix(acs): thread host limits into dispatch-time URL fetches; harden OPA temp dir permissions
Addresses two findings from @MohammadHaroonAbuomar's review:
Finding 4 (Low-Mod): dispatch-time remote bundle and system_prompt_url
fetches used Limits::default() instead of host-configured limits.
OpaRegoRunner and LlmAnnotator now each carry a limits field (defaults to
Limits::default() for zero-config callers) with a with_limits() builder
so the host can propagate its configured limits to both dispatchers.
Finding 5 (Low): the OPA temp directory was created with std::fs::create_dir,
which inherits the process umask. On multi-user Unix hosts this could leave
the bundle world-readable. Replaced with a create_private_dir helper that
uses DirBuilder::mode(0o700) on Unix (Windows is unchanged; ACLs apply).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Imran Siddique <imran.siddique@opaque.co>
* fix(acs): close env-var exfil and SSRF on URL-sourced manifests
Address @MohammadHaroonAbuomar's review of #3101:
- Finding 1 (High): a URL-sourced manifest also controls an llm annotator's
endpoint, so my earlier filesystem-field guard only closed half the exfil.
A remote manifest could still name api_key_env / aws_*_env and ship a host
secret to a chosen endpoint, and the from_url pin is optional so the prior
'the pin closes it' premise does not hold. Now reject host-env secret
annotator fields on URL-sourced manifests; credentials must be inline.
- Finding 2 (Moderate, SSRF): reject loopback and link-local IP destinations
at the URL trust gate (validate_url_components), blocking fetches aimed at
the host itself or cloud metadata (169.254.169.254). RFC1918 stays allowed
for internal hosting; hostname-resolved SSRF, DNS rebinding, and per-redirect
re-validation are documented residual follow-ups.
Update spec sections 2.2 and 2.3 and add tests for both.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* fix(acs): wire host limits into default dispatchers; fix fmt
Finding 4 follow-up: the with_limits() builders added in 99f3a9a were not
reachable from the zero-config default dispatchers, so an operator's tightened
limits were still ignored. Thread Limits end to end: DefaultAnnotatorDispatcher
now carries limits and builds the llm annotator with them, and new
default_annotator_dispatcher_with_limits / default_policy_dispatcher_with_limits
factories wire OpaRegoRunner::with_limits and LlmAnnotator::with_limits. The
existing zero-config factories delegate with default limits, preserving
behavior. Also fixes the cargo fmt break in 99f3a9a (opa.rs).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* fix(acs): import Limits unconditionally in dispatchers module
The Finding 4 wiring put default_annotator_dispatcher_with_limits (not opa
gated) alongside a Limits import that was gated on the opa feature, so a build
with default-dispatchers but without opa (for example
--no-default-features --features openai_moderation) failed to compile with
'cannot find type Limits'. The default opa+cedar build hid it. Move Limits to
an unconditional import; it is a general type used by both the annotator and
the opa policy factories.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* fix(acs): close URL-manifest credential exfil via default-env and bindings; fix IPv6 SSRF bypass
A second deep review of #3101 found the first env-exfil fix was incomplete and
the SSRF guard was bypassable. Three issues, all with regression tests:
1. Default-env credential exfil (High). Rejecting the api_key_env / aws_*_env
fields did not stop the bundled llm dispatcher from falling back to a
provider default credential (OPENAI_API_KEY, AZURE_OPENAI_API_KEY,
GEMINI_API_KEY, and the bedrock AWS_SESSION_TOKEN sent verbatim) and shipping
it to the manifest controlled endpoint. The env var name is a hardcoded
constant, not a manifest field, so a field scan cannot see it. Mark a URL
loaded manifest url_sourced and thread it to the llm dispatcher so it never
reads a host environment credential (explicit or default); credentials must
be inline. Provider agnostic, so it fails closed for future providers too.
2. Binding bypass. The host-secret and system_prompt_file rejection scanned only
annotator declarations, but AnnotatorInvocation::from_annotation overlays
intervention point binding fields, so a binding could inject api_key_env or
system_prompt_file past a clean declaration. Now scan each declaration merged
with its binding as well.
3. SSRF IPv4-mapped IPv6 bypass. is_blocked_fetch_ip missed [::ffff:169.254.169.254]
and [::ffff:127.0.0.1] because Ipv6Addr::is_loopback/link_local are false for
v4-mapped addresses. Canonicalize via to_ipv4_mapped/to_ipv4 before the check.
Update SPECIFICATION.md 2.2 and 2.3.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* style(acs): rustfmt the new regression test and validation lines
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* fix(acs): propagate url_sourced to all host surfaces, drop dead dispatcher factories
Re-review of the maintainer findings showed the URL-manifest credential-exfil
fix only reached the C ABI FFI path. The Rust, Python, and Node SDKs each expose
Manifest::from_url (which sets url_sourced = true) but then built the annotator
dispatcher via default_annotator_dispatcher(), which hardcoded url_sourced =
false, so the High-severity host-credential suppression was silently bypassed on
three of the four host surfaces.
- Make default_annotator_dispatcher_for(manifest, limits) the single factory and
repoint the Rust, Python, and Node SDK host paths at it so provenance flows
from the manifest on every surface.
- Delete the provenance-free factories and constructors that hardcoded
url_sourced = false (default_annotator_dispatcher, *_with_limits, and
DefaultAnnotatorDispatcher::new / ::with_limits). The type can no longer be
constructed without a manifest, so provenance cannot be dropped again. This
also removes the dead factory variants left over from the prior round.
- Inline the dead default_policy_dispatcher_with_limits into
default_policy_dispatcher.
- Add regression tests: from_url_marks_manifest_url_sourced (load sets the flag,
string load does not) and dispatcher_stores_url_sourced_provenance.
The surviving limits parameter is still Limits::default() at every call site
because no FFI or SDK builder limits knob exists yet; that host knob remains the
tracked Finding 4 follow-up.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* fix(acs): reject remote rego bundle_url on URL-sourced manifests (host-env exfil)
Deep review found the URL-manifest credential-exfil fix closed only the llm
dispatcher sink. A URL-sourced (untrusted) manifest can still declare a rego
bundle_url; the bundled OPA dispatcher fetches the attacker-chosen pinned bundle
and runs it via opa eval, which inherits the host environment (opa.runtime().env)
and permits arbitrary egress (http.send). Reproduced: an attacker bundle leaked
AWS_SECRET_ACCESS_KEY to a local sink. This is the same exfil class as the llm
finding but broader (any env var, arbitrary network).
- Reject a rego bundle_url on a URL-sourced manifest at load
(PolicyConfig::reject_url_sourced_remote_bundle, wired into
reject_url_sourced_local_access over the fully extends-merged manifest). The
hash pin does not establish trust because the same untrusted manifest chooses
both the URL and the pin. bundle_url stays fully available to file-sourced,
operator-authored manifests.
- Add regression test from_url_rejects_remote_rego_bundle_url (URL-sourced
bundle_url rejected at load; file-sourced bundle_url still valid).
- Spec 2.3: document the bundle_url prohibition and its rationale; remove
bundle_url from the list of forms a URL-sourced manifest may use.
- Spec 2.2: soften the SSRF wording from 'a fetch cannot target' to 'the
validated fetch URL cannot name', and state that hostname resolution, DNS
rebinding, and redirect hops are not revalidated (honest residual scope).
- Fix rustdoc on the default dispatcher factories that overclaimed 'host
effective Limits' when callers pass Limits::default() (no host knob wired yet).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* feat(acs): revalidate redirect hops and wire host URL-fetch limits
Close the two partial items from @MohammadHaroonAbuomar's review.
Finding 2 (SSRF redirects): the initial fetch URL was IP-checked but ureq
followed redirects with only https_only, so a vetted public URL could 302 to a
loopback, link-local, or internal HTTPS host the initial guard would reject.
HttpExtendsFetcher now sets redirects(0) and follows redirects itself, re-running
validate_url_components (HTTPS plus the SSRF IP block) on every hop before
following it, with the hop count capped. This covers all four URL fetch paths
(from_url, extends, system_prompt_url, bundle_url) since they share the fetcher.
Finding 4 (host limits): every host call site passed Limits::default(), so a
host that tightened max_manifest_url_bytes / manifest_url_timeout_ms /
max_manifest_url_redirects had them ignored at dispatch-time fetches. Add a
focused URL-fetch-limits knob on all four host surfaces:
- FFI: acs_builder_set_url_fetch_limits + AcsBuilder.limits, threaded to both
default dispatcher factories.
- Rust SDK: from_url_with_limits / from_manifest_with_dispatchers_and_limits.
- Python: optional max_url_bytes / url_timeout_ms / max_url_redirects on from_url.
- Node: optional urlFetchLimits on AgentControl.fromUrl.
Re-add default_policy_dispatcher_with_limits (now with real callers).
Tests: redirect-hop re-validation (loopback-https and http-downgrade Location
both blocked) and cap; FFI limits setter + build; Rust/Python/Node from_url
limits threading. Update spec 2.2 to state redirects are re-validated.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
* docs(acs): reword doc comment to satisfy cspell on changed lines
cspell flagged 'exfiltrates' on a changed line; reword to 'sends it out'
without changing meaning.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
---------
Signed-off-by: Liam Crumm <liamcrumm@gmail.com>
Signed-off-by: Imran Siddique <imran.siddique@opaque.co>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Imran Siddique <imran.siddique@opaque.co>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Description
Delivers the edu/K-12 OWASP ASI-mapped starter policy pack requested in #2469, extending the three packs shipped in #1832 (
healthcare,financial-services,general-saas).New file:
examples/policy-templates/edu-k12.yamlEvery policy rule carries explicit ASI risk annotation for auditor traceability.
Domain-specific controls (not present in existing packs)
edu-asi01-homework-bypassedu-asi01-content-filter-bypassedu-asi02-block-grade-mutationedu-asi02-block-record-writeedu-asi03-block-student-impersonationedu-asi06-block-curriculum-poisoningedu-asi09-parental-impersonationedu-asi09-block-minor-contact-infoedu-block-student-idedu-block-phi-iepedu-block-disciplinary-recordedu-cipa-block-adult-contentedu-cipa-block-violence-contentConservative defaults (reflect duty of care for minors)
max_tokensmax_tool_callsconfidence_thresholdcontext_budgetmax_tokens— reachable, leaves headroomDoc updates (
docs/compliance/owasp-asi-policy-mapping.md)edu-k12column added to ASI Risk Coverage Matrix (ASI-09 marked ✅ — explained by footnote on minor duty-of-care exception)edu-k12row added to Default Posture tableRelationship to related issues / PRs
_PII_PATTERNSconsolidation tracked separately in fix: broaden SSN PII regex across integration adapters and consolidate _PII_PATTERNS into shared constant #2635 (not in scope here)Type of Change
Package(s) Affected
Checklist
# ASI-XX:annotation in its messagedefaults:valuesmax_tokens(4,096) — rule is reachable\b\d{3}[\s.-]?\d{2}[\s.-]?\d{4}\bconsistent with other packs