From c31f65abf5179b0774d416ebefaa492691844ff6 Mon Sep 17 00:00:00 2001 From: Helge Sverre Date: Sat, 1 Aug 2026 12:54:14 +0200 Subject: [PATCH] expand standard policy pack --- sema-policies/CHANGELOG.md | 14 ++ sema-policies/README.md | 245 +++++++++++++------- sema-policies/package.sema | 446 +++++++++++++++++++++++++++++++------ sema-policies/sema.toml | 2 +- sema-policies/tests.sema | 220 +++++++++++++++--- 5 files changed, 740 insertions(+), 187 deletions(-) diff --git a/sema-policies/CHANGELOG.md b/sema-policies/CHANGELOG.md index 09112f7..495f186 100644 --- a/sema-policies/CHANGELOG.md +++ b/sema-policies/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.2.0 — 2026-08-01 + +### Added + +- Semantic subject allowlists and reusable file, network, command, and external-action rule builders. +- Configurable input controls and workflow evidence requirements. +- Safe coding-agent, draft-only customer-support, employment-assistance, human-review, and change-control profiles. +- Human-oversight documentation, reviewable-output, certainty-audit, and placeholder-output profiles. +- Composable deny-only profiles for file writes, network requests, commands, and external actions requested through tools. + +### Fixed + +- Constructors reject extra or unknown options instead of silently ignoring them. + ## 0.1.0 — 2026-07-31 ### Added diff --git a/sema-policies/README.md b/sema-policies/README.md index 316e8dd..22d78db 100644 --- a/sema-policies/README.md +++ b/sema-policies/README.md @@ -1,16 +1,10 @@ # sema-policies -Reusable least-privilege, content-safety, output, and workflow-evidence policies. +Reusable least-privilege, content-safety, human-review, output, and workflow-evidence policies. These policies are deterministic runtime controls, not legal or regulatory certifications. Content profiles cover Sema's documented detectors; they do not -classify arbitrary sensitive information. - -Version 0.1 intentionally excludes approval workflows, model rerouting, -domain-specific semantic classifiers, and the third-party HeartFlow rule set. -Those controls need dedicated runtime support or licensed rule definitions; -generic literal and regex output rules are available through -`policies/output-contract`. +classify arbitrary sensitive information or prove that a statement is true. ## Install @@ -18,80 +12,140 @@ generic literal and regex output rules are available through sema pkg add sema-policies ``` -Requires Sema 1.34.0 or newer. +Version 0.2.0 requires Sema 1.34.0 or newer. -## Quick start +## Safe coding agent ```sema (import "sema-policies") (define project-policy - (list - (policies/model-allowlist ["openai/gpt-5"]) - (policies/read-only-repository ["src/**" "Cargo.toml"]) - policies/no-sensitive-data-to-models)) - -(defworkflow inspect "Inspect a repository" + (policies/safe-code-agent + {:models ["openai/gpt-5" "ollama/*"] + :read ["src/**" "tests/**" "Cargo.toml"] + :write ["src/**" "tests/**"] + :commands ["cargo test" "git diff"] + :domains ["docs.rs"]})) + +(defworkflow inspect-and-test "Inspect a repository under a strict policy." {:policy project-policy} (phase "Inspect") + (step "Inspect the project and run its tests." {:tools [read-file write-file run-command]}) {:status :success}) ``` +Commands are exact strings. Network access defaults to `GET` and HTTPS is still +subject to the core domain selector. Tool definitions must declare semantic +policy subjects; an undeclared tool is denied by this profile. + +`:network-methods` is valid only with a non-empty `:domains` list. An explicitly +empty method list is rejected instead of being interpreted as unrestricted. + Policy lists compose as an intersection: every layer must allow an operation, and the strictest content action wins. +## Human review + +`policies/human-reviewed-run` makes a successful run require an applied, +signature-validated approval gate: + +```sema +(import "sema-policies") + +(defworkflow publish-report "Review before publication." + {:policy policies/human-reviewed-run} + + (phase "Draft") + (define report (checkpoint :report (build-report))) + + (phase "Review") + (approval :editor-signoff + {:reason "Publish a public report" + :subject {:kind :external-action + :target "public-site" + :report-digest (hash/sha256 report)} + :preview "Publish the reviewed report"}) + + (phase "Publish") + (publish-report report) + {:status :success}) +``` + +Place the explicit gate immediately before the protected action. The profile +requires that some `approval.applied` event occurred before success; it does not +automatically attach a gate to a matching tool call or prove that a particular +gate authorized a particular later action. + +`policies/change-controlled` also requires `:owner`, `:change-id`, and +`:environment` workflow metadata. + ## API -| Function or value | Description | -|---|---| -| `(policies/model-allowlist identities opts?)` | Allow specific `provider/model` identities | -| `(policies/tool-allowlist rules opts?)` | Allow named tools with argument constraints | -| `(policies/read-only-repository paths)` | Allow declared file reads under selected paths | -| `(policies/output-contract contract)` | Enforce LLM-output schema, required fields, limits, patterns, or detectors | -| `policies/no-tools` | Deny every model-requested tool call | -| `policies/no-sensitive-data-to-models` | Block secrets/cards and redact supported personal-data patterns before dispatch | -| `policies/public-content` | Block every supported sensitive-content detector on input and output | -| `policies/public-sector-rag` | Require ownership/source metadata, structured cited LLM output, content controls, and a checkpoint | -| `policies/ai-act-docs-lite` | Require basic ownership/risk/purpose metadata, structured LLM decision records, and a checkpoint | +### Constructors and rule builders -### `policies/model-allowlist` +| Function | Description | +|---|---| +| `(policies/model-allowlist identities opts?)` | Allow exact `provider/model` identities or `provider/*` | +| `(policies/tool-allowlist rules opts?)` | Allow named tools with path, domain, or exact-command constraints | +| `(policies/subject-allowlist rules opts?)` | Allow only matching semantic tool subjects; `:deny` rules take precedence | +| `(policies/file-read-rule paths)` | Build a `:file-read` subject rule | +| `(policies/file-write-rule paths)` | Build a `:file-write` subject rule | +| `(policies/file-delete-rule paths)` | Build a `:file-delete` subject rule | +| `(policies/network-rule domains opts?)` | Build a network rule; `:methods` restricts HTTP methods | +| `(policies/command-rule commands)` | Build an exact-command rule | +| `(policies/external-action-rule actions)` | Build a rule for exact declared action names | +| `(policies/input-controls detectors opts?)` | Configure deterministic input detectors and actions | +| `(policies/evidence-requirements requirements)` | Require workflow metadata and/or event kinds | +| `(policies/read-only-repository paths)` | Allow declared file reads under selected paths and deny other subject kinds | +| `(policies/output-contract contract)` | Enforce an arbitrary core output policy map | + +Use one combined `policies/subject-allowlist` when several subject kinds must be +allowed. Two separate default-deny subject policies intersect and can deny each +other's otherwise valid operations. ```sema -(policies/model-allowlist - ["openai/gpt-5" "ollama/*"] - {:on-deny :skip}) +(policies/subject-allowlist + [(policies/file-read-rule ["src/**" "Cargo.toml"]) + (policies/file-write-rule {:allow ["src/**"] + :deny ["src/generated/**"]}) + (policies/network-rule ["api.example.com"] {:methods ["GET"]}) + (policies/command-rule ["cargo test"]) + (policies/external-action-rule ["ticket-read"])]) ``` -`identities` is a non-empty list or vector. Exact `provider/model` identities -and the `provider/*` wildcard are supported. `:on-deny :skip` is useful inside -fallback routing; the default is `:fail`. +Path and domain arguments accept either a non-empty shorthand sequence or the +core selector map with `:allow` and `:deny`. Domain selectors may also specify +`:schemes` and `:ports`. -### `policies/tool-allowlist` +### Configurable input controls ```sema -(policies/tool-allowlist - {"read-file" {:paths ["src/**" "Cargo.toml"]} - "fetch-url" {:domains {:allow ["docs.example.com"] - :schemes ["https"] - :ports [443]}}}) +(policies/input-controls + [:secret :payment-card :email] + {:actions {:secret :block + :payment-card :block + :email :redact}}) ``` -Map each allowed tool name to its path, domain, or exact-command constraints. -The default denial becomes a tool-visible error; pass `{:on-deny :fail}` to -abort the run instead. +Supported detector families are `:secret`, `:payment-card`, `:email`, `:phone`, +and `:ipv4`. Actions are `:audit`, `:redact`, or `:block`; a detector without an +explicit action blocks. -### `policies/read-only-repository` +### Evidence requirements ```sema -(policies/read-only-repository ["src/**" "crates/**" "Cargo.toml"]) +(policies/evidence-requirements + {:metadata [:owner :intended-purpose] + :events [:checkpoint :approval.applied]}) ``` -This profile evaluates semantic subjects declared by `deftool`, so it is -independent of tool names. It permits matching `:file-read` subjects and denies -file writes/deletes, commands, network requests, external actions, undeclared -subjects, and paths outside the workspace. +Metadata values must be present and non-empty. Completion requirements match +event kinds, not event fields. They cannot yet require a particular checkpoint +key, tool name, command, or approval key. + +### Output controls -### `policies/output-contract` +`policies/output-contract` exposes the core `:output` map: ```sema (policies/output-contract @@ -102,58 +156,77 @@ subjects, and paths outside the workspace. :forbid [{:id :no-placeholder :contains "TODO"}]}) ``` -The contract is the core `:output` policy map. Supported field types are -`:string`, `:number`, `:boolean`, and `:list`. Structural checks run on the -terminal LLM response; detector and forbidden-pattern checks also guard agent -rounds and buffered streams. This does not validate the value returned by the -workflow body. +Supported field types are `:string`, `:number`, `:boolean`, and `:list`. +Structural checks run on the terminal LLM response. Detector and forbidden +pattern checks also guard agent rounds and buffered streams. They do not +validate the value returned directly by the workflow body. -### `policies/no-tools` +The fixed output profiles are: -```sema -{:policy policies/no-tools} -``` - -Denies all model-requested tools. It does not remove ordinary Sema capabilities; -use workflow `:permissions` for the outer sandbox ceiling. - -### `policies/no-sensitive-data-to-models` +| Policy | Behavior | +|---|---| +| `policies/reviewable-output` | Require non-empty `answer`, `sources`, and `limitations`; allow optional numeric `confidence` | +| `policies/certainty-audit` | Journal a small, independently authored set of absolute-certainty and unnamed-consensus patterns without blocking | +| `policies/no-placeholder-output` | Block objective unfinished-template markers | -Blocks detected secrets and payment-card numbers before provider dispatch. -Detected email addresses, phone numbers, and IPv4 addresses are replaced with -typed redaction markers. Detectors are deterministic patterns and may have -false positives or false negatives. +`certainty-audit` is intentionally advisory. Quoted text and valid contractual +language can match its patterns. It is not a port of HeartFlow and does not +implement semantic truth, fallacy, manipulation, or factuality classification. -### `policies/public-content` +### Deny-only profiles -Blocks the supported secret, payment-card, email, phone, and IPv4 detectors on -input and output. The name describes the intended data boundary, not an -automatic proof that otherwise-unmatched content is public. +These policies use `:subjects {:default :allow ...}` so they can tighten a +separate allowlist without denying unrelated subject kinds: -### `policies/public-sector-rag` +| Policy | Denies through model-invoked/direct `deftool` dispatch | +|---|---| +| `policies/no-file-write-tools` | File writes and deletes | +| `policies/no-network-tools` | Network requests | +| `policies/no-command-tools` | Commands | +| `policies/no-external-action-tools` | External actions | -Requires non-empty workflow metadata keys `:owner`, `:data-classification`, and -`:source`; a terminal LLM JSON object with non-empty `answer` and `sources`; the -standard content controls; and at least one checkpoint event. It is a practical -baseline for auditable RAG, not a public-sector compliance certification. +The names include `tools` because semantic policy subjects govern declared +tool dispatch. Ordinary Sema filesystem, HTTP, shell, and MCP calls remain +governed by workflow `:permissions` and the outer sandbox. -### `policies/ai-act-docs-lite` +### Fixed baselines and domain profiles -Requires non-empty `:owner`, `:risk-tier`, and `:intended-purpose` metadata; a -terminal LLM decision record with `decision`, `rationale`, and `sources`; and -at least one checkpoint. It supports documentation workflows but does not by -itself establish EU AI Act compliance. +| Policy | Behavior | +|---|---| +| `policies/no-tools` | Deny every model-requested tool call | +| `policies/no-sensitive-data-to-models` | Block detected secrets/cards and redact supported personal-data patterns before provider dispatch | +| `policies/public-content` | Block every supported sensitive-content detector on LLM input and output | +| `(policies/safe-code-agent options)` | Combine model, semantic subject, and input controls for coding agents | +| `(policies/customer-support-safe options)` | Draft-only support profile that permits exact read-only external-action names and requires structured drafts | +| `policies/human-reviewed-run` | Require an applied approval before workflow success | +| `policies/change-controlled` | Require change metadata and an applied approval | +| `policies/public-sector-rag` | Require ownership/source metadata, structured cited output, content controls, and a checkpoint | +| `policies/ai-act-docs-lite` | Require basic ownership/risk/purpose metadata, structured decision records, and a checkpoint | +| `policies/ai-act-human-oversight` | Require fuller purpose/affected-person/oversight metadata, structured limitations, a checkpoint, and approval | +| `policies/employment-assist` | Disable model tools, require neutral evidence/limitations/review output and metadata, and require approval | + +`customer-support-safe` is deliberately draft-only. Its `:read-actions` must +match the `:external-action` values declared by the permitted read tools. Any +additional subject declared by those tools must also be allowed by a different, +carefully designed profile; policy composition cannot loosen this profile. + +`employment-assist` does not provide protected-attribute or health detectors. +It blocks model tools and enforces a review-shaped output and explicit approval; +it is not a candidate-ranking or employment-decision system. + +The AI documentation profiles generate runtime evidence requirements. They do +not establish EU AI Act compliance. ## Evidence export -Export a completed workflow's generic evidence bundle with: - ```bash sema workflow export ``` -The command writes a JSON event ledger, Markdown summary, and SHA-256 manifest -under the run directory by default. +The command writes a JSON ledger, Markdown summary, and SHA-256 manifest under +the run directory by default. Approval request and decision summaries are read +through Sema's digest and Ed25519 signature validation. The authoritative +approval sidecars are included in the integrity manifest. ## Testing diff --git a/sema-policies/package.sema b/sema-policies/package.sema index a3a3ef5..f30e3f5 100644 --- a/sema-policies/package.sema +++ b/sema-policies/package.sema @@ -1,15 +1,38 @@ ;; sema-policies — reusable deterministic policies for Sema workflows. (module sema-policies - (export policies/model-allowlist - policies/tool-allowlist - policies/read-only-repository - policies/output-contract - policies/no-tools - policies/no-sensitive-data-to-models - policies/public-content - policies/public-sector-rag - policies/ai-act-docs-lite) + (export + policies/model-allowlist + policies/tool-allowlist + policies/subject-allowlist + policies/file-read-rule + policies/file-write-rule + policies/file-delete-rule + policies/network-rule + policies/command-rule + policies/external-action-rule + policies/input-controls + policies/evidence-requirements + policies/read-only-repository + policies/safe-code-agent + policies/customer-support-safe + policies/output-contract + policies/no-tools + policies/no-file-write-tools + policies/no-network-tools + policies/no-command-tools + policies/no-external-action-tools + policies/no-sensitive-data-to-models + policies/public-content + policies/reviewable-output + policies/certainty-audit + policies/no-placeholder-output + policies/human-reviewed-run + policies/change-controlled + policies/public-sector-rag + policies/ai-act-docs-lite + policies/ai-act-human-oversight + policies/employment-assist) (define (named-policy name rules) (assoc (assoc rules :__policy-name name) :__policy-version 1)) @@ -17,102 +40,387 @@ (define (non-empty-sequence? value) (and (or (list? value) (vector? value)) (not (empty? value)))) + (define (constraint? value) + (or (and (map? value) (not (empty? value))) + (non-empty-sequence? value))) + + (define (single-options who opts allowed) + (when (> (length opts) 1) + (error (format "~a: expected at most one options map" who))) + (let ((options (if (null? opts) {} (first opts)))) + (when (not (map? options)) + (error (format "~a: options must be a map" who))) + (let ((unknown + (filter (fn (key) (not (member key allowed))) (keys options)))) + (when (not (null? unknown)) + (error (format "~a: unknown option ~a" who (first unknown))))) + options)) + + (define (require-constraint who label value) + (when (not (constraint? value)) + (error + (format "~a: ~a must be a non-empty selector map, list, or vector" + who + label)))) + + (define (require-optional-sequence who label value) + (when (not (or (list? value) (vector? value))) + (error (format "~a: ~a must be a list or vector" who label)))) + (define (policies/model-allowlist identities . opts) "Allow only the provider/model identities in identities. Options: :on-deny is :fail (default) or :skip for fallback routing." (when (not (non-empty-sequence? identities)) - (error "policies/model-allowlist: identities must be a non-empty list or vector — use provider/model strings")) - (let ((o (if (null? opts) {} (first opts)))) - (when (not (map? o)) - (error "policies/model-allowlist: options must be a map — use {:on-deny :fail}")) - (named-policy - "policies/model-allowlist" + (error + "policies/model-allowlist: identities must be a non-empty list or vector — use provider/model strings")) + (let ((o (single-options "policies/model-allowlist" opts [:on-deny]))) + (named-policy "policies/model-allowlist" {:models {:default :deny - :allow identities - :on-deny (get o :on-deny :fail)}}))) + :allow identities + :on-deny (get o :on-deny :fail)}}))) (define (policies/tool-allowlist rules . opts) "Allow only tools in rules, where each map key is a tool name and each value is its argument constraint map. Options: :on-deny is :tool-error (default) or :fail." (when (or (not (map? rules)) (empty? rules)) - (error "policies/tool-allowlist: rules must be a non-empty map — map each allowed tool name to its constraints")) - (let ((o (if (null? opts) {} (first opts)))) - (when (not (map? o)) - (error "policies/tool-allowlist: options must be a map — use {:on-deny :tool-error}")) - (named-policy - "policies/tool-allowlist" + (error + "policies/tool-allowlist: rules must be a non-empty map — map each allowed tool name to its constraints")) + (let ((o (single-options "policies/tool-allowlist" opts [:on-deny]))) + (named-policy "policies/tool-allowlist" {:tools {:default :deny - :allow rules - :on-deny (get o :on-deny :tool-error)}}))) + :allow rules + :on-deny (get o :on-deny :tool-error)}}))) + + (define (policies/file-read-rule paths) + "Build a semantic file-read rule for a subject allowlist." + (require-constraint "policies/file-read-rule" "paths" paths) + {:kind :file-read :paths paths}) + + (define (policies/file-write-rule paths) + "Build a semantic file-write rule for a subject allowlist." + (require-constraint "policies/file-write-rule" "paths" paths) + {:kind :file-write :paths paths}) + + (define (policies/file-delete-rule paths) + "Build a semantic file-delete rule for a subject allowlist." + (require-constraint "policies/file-delete-rule" "paths" paths) + {:kind :file-delete :paths paths}) + + (define (policies/network-rule domains . opts) + "Build a semantic network-request rule. Options: :methods is a list or vector of HTTP methods." + (require-constraint "policies/network-rule" "domains" domains) + (let* ((o (single-options "policies/network-rule" opts [:methods])) + (methods (get o :methods [])) + (rule {:kind :network-request :domains domains})) + (require-optional-sequence "policies/network-rule" "methods" methods) + (when (and (contains? o :methods) (empty? methods)) + (error "policies/network-rule: :methods must not be empty when provided")) + (if (empty? methods) rule (assoc rule :methods methods)))) + + (define (policies/command-rule commands) + "Build a semantic command rule. Commands are exact strings, not shell patterns." + (require-constraint "policies/command-rule" "commands" commands) + {:kind :command :commands commands}) + + (define (policies/external-action-rule actions) + "Build a semantic external-action rule for exact declared action names." + (when (not (non-empty-sequence? actions)) + (error + "policies/external-action-rule: actions must be a non-empty list or vector")) + {:kind :external-action :actions actions}) + + (define (policies/subject-allowlist rules . opts) + "Allow only declared semantic subjects matching rules. Options: :deny adds rules that take precedence." + (when (not (non-empty-sequence? rules)) + (error + "policies/subject-allowlist: rules must be a non-empty list or vector")) + (let* ((o (single-options "policies/subject-allowlist" opts [:deny])) + (deny (get o :deny []))) + (require-optional-sequence "policies/subject-allowlist" "deny" deny) + (named-policy "policies/subject-allowlist" + {:subjects {:default :deny :allow rules :deny deny}}))) + + (define (policies/input-controls detectors . opts) + "Apply deterministic input detectors. Options: :actions maps detector names to :audit, :redact, or :block. Undeclared actions default to :block." + (when (not (non-empty-sequence? detectors)) + (error + "policies/input-controls: detectors must be a non-empty list or vector")) + (let* ((o (single-options "policies/input-controls" opts [:actions])) + (actions (get o :actions {}))) + (when (not (map? actions)) + (error "policies/input-controls: :actions must be a map")) + (named-policy "policies/input-controls" + {:input {:detect detectors :actions actions}}))) + + (define (policies/evidence-requirements requirements) + "Require workflow metadata and/or event kinds. Use {:metadata [...] :events [...]}." + (when (or (not (map? requirements)) (empty? requirements)) + (error + "policies/evidence-requirements: requirements must be a non-empty map")) + (let ((unknown + (filter (fn (key) (not (member key [:metadata :events]))) + (keys requirements)))) + (when (not (null? unknown)) + (error + (format "policies/evidence-requirements: unknown key ~a" + (first unknown))))) + (let* ((metadata (get requirements :metadata [])) + (events (get requirements :events [])) + (rules {})) + (require-optional-sequence "policies/evidence-requirements" + "metadata" + metadata) + (require-optional-sequence "policies/evidence-requirements" + "events" + events) + (when (and (empty? metadata) (empty? events)) + (error + "policies/evidence-requirements: provide at least one metadata key or event")) + (when (not (empty? metadata)) + (set! rules (assoc rules :metadata {:require metadata}))) + (when (not (empty? events)) + (set! rules (assoc rules :completion {:require-events events}))) + (named-policy "policies/evidence-requirements" rules))) (define (policies/read-only-repository paths) "Allow declared file-read subjects only within paths, and deny writes, deletes, commands, network requests, and external actions." (when (not (non-empty-sequence? paths)) - (error "policies/read-only-repository: paths must be a non-empty list or vector — use workspace-relative glob strings")) - (named-policy - "policies/read-only-repository" - {:subjects - {:default :deny + (error + "policies/read-only-repository: paths must be a non-empty list or vector — use workspace-relative glob strings")) + (named-policy "policies/read-only-repository" + {:subjects {:default :deny :allow [{:kind :file-read :paths paths}] :deny [{:kind :file-write} - {:kind :file-delete} - {:kind :command} - {:kind :network-request} - {:kind :external-action}]}})) + {:kind :file-delete} + {:kind :command} + {:kind :network-request} + {:kind :external-action}]}})) + + (define (policies/safe-code-agent options) + "Build a strict coding-agent profile. Required: :models and :read. Optional: :write, :commands, :domains, and :network-methods." + (when (not (map? options)) + (error "policies/safe-code-agent: options must be a map")) + (let ((unknown + (filter + (fn (key) + (not + (member key + [:models :read :write :commands :domains :network-methods]))) + (keys options)))) + (when (not (null? unknown)) + (error + (format "policies/safe-code-agent: unknown option ~a" (first unknown))))) + (let* ((models (get options :models [])) + (read-paths (get options :read [])) + (write-paths (get options :write [])) + (commands (get options :commands [])) + (domains (get options :domains [])) + (methods (get options :network-methods ["GET"])) + (rules [])) + (when (not (non-empty-sequence? models)) + (error + "policies/safe-code-agent: :models must be a non-empty list or vector")) + (when (not (non-empty-sequence? read-paths)) + (error + "policies/safe-code-agent: :read must be a non-empty list or vector")) + (require-optional-sequence "policies/safe-code-agent" "write" write-paths) + (require-optional-sequence "policies/safe-code-agent" "commands" commands) + (require-optional-sequence "policies/safe-code-agent" "domains" domains) + (require-optional-sequence "policies/safe-code-agent" + "network-methods" + methods) + (when (and (contains? options :network-methods) (empty? methods)) + (error + "policies/safe-code-agent: :network-methods must not be empty when provided")) + (when (and (contains? options :network-methods) (empty? domains)) + (error + "policies/safe-code-agent: :network-methods requires non-empty :domains")) + (set! rules (append rules (list (policies/file-read-rule read-paths)))) + (when (not (empty? write-paths)) + (set! rules + (append rules (list (policies/file-write-rule write-paths))))) + (when (not (empty? commands)) + (set! rules (append rules (list (policies/command-rule commands))))) + (when (not (empty? domains)) + (set! rules + (append rules + (list + (policies/network-rule {:allow domains :schemes ["https"]} + {:methods methods}))))) + (named-policy "policies/safe-code-agent" + {:models {:default :deny :allow models :on-deny :fail} + :tools {:default :allow :on-deny :tool-error} + :subjects {:default :deny :allow rules} + :input {:detect [:secret :payment-card :email :phone :ipv4] + :actions {:secret :block + :payment-card :block + :email :redact + :phone :redact + :ipv4 :redact}}}))) + + (define (policies/customer-support-safe options) + "Build a draft-only support profile. :read-actions lists exact external-action names for non-mutating CRM or ticket reads." + (when (not (map? options)) + (error "policies/customer-support-safe: options must be a map")) + (let ((unknown + (filter (fn (key) (not (member key [:read-actions]))) + (keys options)))) + (when (not (null? unknown)) + (error + (format "policies/customer-support-safe: unknown option ~a" + (first unknown))))) + (let ((read-actions (get options :read-actions []))) + (when (not (non-empty-sequence? read-actions)) + (error + "policies/customer-support-safe: :read-actions must be a non-empty list or vector")) + (named-policy "policies/customer-support-safe" + {:metadata {:require [:owner :intended-purpose]} + :tools {:default :allow :on-deny :tool-error} + :subjects {:default :deny + :allow [{:kind :external-action :actions read-actions}]} + :input {:detect [:secret :payment-card :email :phone :ipv4] + :actions {:secret :block + :payment-card :block + :email :redact + :phone :redact + :ipv4 :redact}} + :output {:schema {:draft {:type :string} + :sources {:type :list} + :limitations {:type :string}} + :require [:draft :sources :limitations]}}))) (define (policies/output-contract contract) "Require an output contract map using :schema, :require, :max-length, :forbid, :detect, :actions, and/or :action." (when (or (not (map? contract)) (empty? contract)) - (error "policies/output-contract: contract must be a non-empty map — provide at least one output constraint")) + (error + "policies/output-contract: contract must be a non-empty map — provide at least one output constraint")) (named-policy "policies/output-contract" {:output contract})) (define policies/no-tools - (named-policy - "policies/no-tools" + (named-policy "policies/no-tools" {:tools {:default :deny :on-deny :fail}})) + ;; Deny-only subject layers use :default :allow so they compose with a separate + ;; strict subject allowlist without denying unrelated subject kinds. + (define policies/no-file-write-tools + (named-policy "policies/no-file-write-tools" + {:subjects {:default :allow + :deny [{:kind :file-write} {:kind :file-delete}]}})) + + (define policies/no-network-tools + (named-policy "policies/no-network-tools" + {:subjects {:default :allow :deny [{:kind :network-request}]}})) + + (define policies/no-command-tools + (named-policy "policies/no-command-tools" + {:subjects {:default :allow :deny [{:kind :command}]}})) + + (define policies/no-external-action-tools + (named-policy "policies/no-external-action-tools" + {:subjects {:default :allow :deny [{:kind :external-action}]}})) + (define policies/no-sensitive-data-to-models - (named-policy - "policies/no-sensitive-data-to-models" - {:input - {:detect [:secret :payment-card :email :phone :ipv4] + (named-policy "policies/no-sensitive-data-to-models" + {:input {:detect [:secret :payment-card :email :phone :ipv4] :actions {:secret :block - :payment-card :block - :email :redact - :phone :redact - :ipv4 :redact}}})) + :payment-card :block + :email :redact + :phone :redact + :ipv4 :redact}}})) (define policies/public-content - (named-policy - "policies/public-content" - {:input - {:detect [:secret :payment-card :email :phone :ipv4]} - :output - {:detect [:secret :payment-card :email :phone :ipv4]}})) + (named-policy "policies/public-content" + {:input {:detect [:secret :payment-card :email :phone :ipv4]} + :output {:detect [:secret :payment-card :email :phone :ipv4]}})) + + (define policies/reviewable-output + (named-policy "policies/reviewable-output" + {:output {:schema {:answer {:type :string} + :sources {:type :list} + :limitations {:type :string} + :confidence {:type :number :optional #t}} + :require [:answer :sources :limitations]}})) + + (define policies/certainty-audit + (named-policy "policies/certainty-audit" + {:output {:action :audit + :forbid [{:id :absolute-guarantee + :regex "(?i)\\b(guaranteed|zero[- ]risk|100%[[:space:]]+(safe|effective|accurate))\\b"} + {:id :exclusive-certainty + :regex "(?i)\\b(the only correct (answer|solution)|cannot possibly fail)\\b"} + {:id :unnamed-consensus + :regex "(?i)\\b(experts agree|everyone knows|studies (show|prove))\\b"}]}})) + + (define policies/no-placeholder-output + (named-policy "policies/no-placeholder-output" + {:output {:forbid [{:id :unfinished-placeholder + :regex "(?i)\\b(TODO|TBD|FIXME)\\b|\\[(INSERT|REPLACE)[^]]*\\]|\\{\\{[^{}]+\\}\\}"}]}})) + + (define policies/human-reviewed-run + (named-policy "policies/human-reviewed-run" + {:completion {:require-events [:approval.applied]}})) + + (define policies/change-controlled + (named-policy "policies/change-controlled" + {:metadata {:require [:owner :change-id :environment]} + :completion {:require-events [:approval.applied]}})) (define policies/public-sector-rag - (named-policy - "policies/public-sector-rag" + (named-policy "policies/public-sector-rag" {:metadata {:require [:owner :data-classification :source]} - :input - {:detect [:secret :payment-card :email :phone :ipv4] + :input {:detect [:secret :payment-card :email :phone :ipv4] :actions {:secret :block - :payment-card :block - :email :redact - :phone :redact - :ipv4 :redact}} - :output - {:detect [:secret :payment-card :email :phone] + :payment-card :block + :email :redact + :phone :redact + :ipv4 :redact}} + :output {:detect [:secret :payment-card :email :phone] :schema {:answer {:type :string} - :sources {:type :list}} + :sources {:type :list}} :require [:answer :sources]} :completion {:require-events [:checkpoint]}})) (define policies/ai-act-docs-lite - (named-policy - "policies/ai-act-docs-lite" + (named-policy "policies/ai-act-docs-lite" {:metadata {:require [:owner :risk-tier :intended-purpose]} - :output - {:schema {:decision {:type :string} - :rationale {:type :string} - :sources {:type :list}} + :output {:schema {:decision {:type :string} + :rationale {:type :string} + :sources {:type :list}} :require [:decision :rationale :sources]} - :completion {:require-events [:checkpoint]}}))) + :completion {:require-events [:checkpoint]}})) + + (define policies/ai-act-human-oversight + (named-policy "policies/ai-act-human-oversight" + {:metadata {:require [:owner + :risk-tier + :intended-purpose + :not-for + :affected-persons + :users + :models + :human-oversight]} + :output {:schema {:decision {:type :string} + :rationale {:type :string} + :sources {:type :list} + :limitations {:type :string}} + :require [:decision :rationale :sources :limitations]} + :completion {:require-events [:checkpoint :approval.applied]}})) + + (define policies/employment-assist + (named-policy "policies/employment-assist" + {:metadata {:require [:owner + :intended-purpose + :not-for + :affected-persons + :human-oversight]} + :tools {:default :deny :on-deny :fail} + :input {:detect [:secret :payment-card :email :phone :ipv4] + :actions {:secret :block + :payment-card :block + :email :redact + :phone :redact + :ipv4 :redact}} + :output {:schema {:summary {:type :string} + :evidence {:type :list} + :limitations {:type :string} + :review-notice {:type :string}} + :require [:summary :evidence :limitations :review-notice]} + :completion {:require-events [:approval.applied]}}))) diff --git a/sema-policies/sema.toml b/sema-policies/sema.toml index 25ad03f..93e353c 100644 --- a/sema-policies/sema.toml +++ b/sema-policies/sema.toml @@ -1,6 +1,6 @@ [package] name = "sema-policies" -version = "0.1.0" +version = "0.2.0" description = "Reusable least-privilege, content-safety, output, and workflow-evidence policies" entrypoint = "package.sema" license = "MIT" diff --git a/sema-policies/tests.sema b/sema-policies/tests.sema index 5bd8ad1..edd2920 100644 --- a/sema-policies/tests.sema +++ b/sema-policies/tests.sema @@ -7,46 +7,143 @@ (let ((models (policies/model-allowlist ["openai/gpt-5"])) (tools (policies/tool-allowlist {"read-file" {:paths ["src/**"]}})) (repository (policies/read-only-repository ["src/**" "Cargo.toml"])) - (contract (policies/output-contract - {:schema {:answer {:type :string}} - :require [:answer]}))) + (contract + (policies/output-contract + {:schema {:answer {:type :string}} + :require [:answer]}))) (test/assert-equal :deny (get (get models :models) :default)) (test/assert-equal :tool-error (get (get tools :tools) :on-deny)) (test/assert-equal :file-read (get (first (get (get repository :subjects) :allow)) :kind)) (test/assert-equal [:answer] (get (get contract :output) :require)))) -(define (constructors-reject-empty-input) +(define (constructors-reject_empty_or_extra_input) (test/assert-throws (fn () (policies/model-allowlist []))) (test/assert-throws (fn () (policies/tool-allowlist {}))) (test/assert-throws (fn () (policies/read-only-repository []))) - (test/assert-throws (fn () (policies/output-contract {})))) + (test/assert-throws (fn () (policies/output-contract {}))) + (test/assert-throws (fn () (policies/model-allowlist ["openai/gpt-5"] {} {}))) + (test/assert-throws + (fn () (policies/tool-allowlist {"read" {}} {:unknown #t}))) + (test/assert-throws + (fn () (policies/network-rule ["api.example.com"] {:methods []}))) + (test/assert-throws + (fn () + (policies/safe-code-agent + {:models ["openai/gpt-5"] + :read ["src/**"] + :network-methods ["POST"]})))) + +(define (semantic-rule-builders-compose) + (let* ((read (policies/file-read-rule ["src/**"])) + (write + (policies/file-write-rule + {:allow ["src/**"] + :deny ["src/generated/**"]})) + (delete (policies/file-delete-rule ["tmp/**"])) + (network + (policies/network-rule ["api.example.com"] + {:methods ["GET"]})) + (command (policies/command-rule ["cargo test"])) + (external (policies/external-action-rule ["ticket-read"])) + (policy + (policies/subject-allowlist + [read write delete network command external] + {:deny [{:kind :file-delete :paths ["src/**"]}]}))) + (test/assert-equal :network-request (get network :kind)) + (test/assert-equal ["GET"] (get network :methods)) + (test/assert-equal :deny (get (get policy :subjects) :default)) + (test/assert-equal 6 (length (get (get policy :subjects) :allow))) + (test/assert-equal :file-delete + (get (first (get (get policy :subjects) :deny)) :kind)))) + +(define (configurable-input-and-evidence-controls) + (let ((input + (policies/input-controls [:secret :email] + {:actions {:secret :block :email :redact}})) + (evidence + (policies/evidence-requirements + {:metadata [:owner] :events [:approval.applied]}))) + (test/assert-equal :redact + (get (get (get input :input) :actions) :email)) + (test/assert-equal [:owner] + (get (get evidence :metadata) :require)) + (test/assert-equal [:approval.applied] + (get (get evidence :completion) :require-events)))) + +(define (safe-code-and-support-profiles-are-strict) + (let* ((safe + (policies/safe-code-agent + {:models ["openai/gpt-5"] + :read ["src/**" "Cargo.toml"] + :write ["src/**" "tests/**"] + :commands ["cargo test" "git diff"] + :domains ["docs.rs"]})) + (support + (policies/customer-support-safe + {:read-actions ["crm-read" "ticket-read"]}))) + (test/assert-equal "policies/safe-code-agent" (get safe :__policy-name)) + (test/assert-equal :deny (get (get safe :subjects) :default)) + (test/assert-equal 4 (length (get (get safe :subjects) :allow))) + (let ((network + (first + (filter (fn (rule) (= :network-request (get rule :kind))) + (get (get safe :subjects) :allow))))) + (test/assert-equal ["https"] + (get (get network :domains) :schemes))) + (test/assert-equal :deny (get (get support :subjects) :default)) + (test/assert-equal ["crm-read" "ticket-read"] + (get (first (get (get support :subjects) :allow)) :actions)))) + +(define (deny-only-profiles-compose-with-default-allow) + (for-each + (fn (policy) + (test/assert-equal :allow (get (get policy :subjects) :default)) + (test/assert-true (not (empty? (get (get policy :subjects) :deny))))) + [policies/no-file-write-tools + policies/no-network-tools + policies/no-command-tools + policies/no-external-action-tools])) (define (fixed-profiles-carry-stable-names) - (test/assert-equal "policies/no-tools" - (get policies/no-tools :__policy-name)) - (test/assert-equal "policies/no-sensitive-data-to-models" - (get policies/no-sensitive-data-to-models :__policy-name)) - (test/assert-equal "policies/public-content" - (get policies/public-content :__policy-name)) - (test/assert-equal "policies/public-sector-rag" - (get policies/public-sector-rag :__policy-name)) - (test/assert-equal "policies/ai-act-docs-lite" - (get policies/ai-act-docs-lite :__policy-name))) + (for-each + (fn (entry) + (test/assert-equal (first entry) (get (nth entry 1) :__policy-name))) + [["policies/no-tools" policies/no-tools] + ["policies/no-sensitive-data-to-models" + policies/no-sensitive-data-to-models] + ["policies/public-content" policies/public-content] + ["policies/reviewable-output" policies/reviewable-output] + ["policies/certainty-audit" policies/certainty-audit] + ["policies/no-placeholder-output" policies/no-placeholder-output] + ["policies/human-reviewed-run" policies/human-reviewed-run] + ["policies/change-controlled" policies/change-controlled] + ["policies/public-sector-rag" policies/public-sector-rag] + ["policies/ai-act-docs-lite" policies/ai-act-docs-lite] + ["policies/ai-act-human-oversight" policies/ai-act-human-oversight] + ["policies/employment-assist" policies/employment-assist]])) -(define (fixed-profiles-compile-in-workflows) - (let ((no-tools - (defworkflow package-no-tools "compile no-tools" - {:policy policies/no-tools} +(define (non-approval-profiles-compile-in-workflows) + (let ((safe + (defworkflow package-safe-code + "compile safe-code-agent" + {:policy (policies/safe-code-agent + {:models ["openai/gpt-5"] + :read ["src/**"] + :commands ["cargo test"]})} (phase "Check") {:status :success})) - (public - (defworkflow package-public-content "compile public-content" - {:policy policies/public-content} + (support + (defworkflow package-support + "compile customer-support-safe" + {:policy (policies/customer-support-safe {:read-actions ["ticket-read"]}) + :owner "support-team" + :intended-purpose "Draft replies for review"} (phase "Check") {:status :success})) (rag - (defworkflow package-public-sector-rag "compile public-sector-rag" + (defworkflow package-public-sector-rag + "compile public-sector-rag" {:policy policies/public-sector-rag :owner "records-team" :data-classification "internal" @@ -55,21 +152,82 @@ (checkpoint :evidence #t) {:status :success})) (docs - (defworkflow package-ai-act-docs "compile ai-act-docs-lite" + (defworkflow package-ai-act-docs + "compile ai-act-docs-lite" {:policy policies/ai-act-docs-lite :owner "risk-team" :risk-tier "limited" :intended-purpose "package test"} (phase "Check") (checkpoint :evidence #t) + {:status :success})) + (output-controls + (defworkflow package-output-controls + "compile output controls" + {:policy [policies/reviewable-output + policies/certainty-audit + policies/no-placeholder-output]} + (phase "Check") {:status :success}))) - (test/assert-equal :success (get no-tools :status)) - (test/assert-equal :success (get public :status)) + (test/assert-equal :success (get safe :status)) + (test/assert-equal :success (get support :status)) (test/assert-equal :success (get rag :status)) - (test/assert-equal :success (get docs :status)))) + (test/assert-equal :success (get docs :status)) + (test/assert-equal :success (get output-controls :status)))) + +(define (approval-profiles-fail-closed-without-review) + (let ((reviewed + (defworkflow package-human-review + "require approval evidence" + {:policy policies/human-reviewed-run} + (phase "Review") + {:status :success})) + (controlled + (defworkflow package-change-control + "require approval and metadata" + {:policy policies/change-controlled + :owner "release-team" + :change-id "CHG-42" + :environment "production"} + (phase "Review") + {:status :success})) + (oversight + (defworkflow package-human-oversight + "require documented oversight" + {:policy policies/ai-act-human-oversight + :owner "risk-team" + :risk-tier "high" + :intended-purpose "Assist a human reviewer" + :not-for "Automated final decisions" + :affected-persons ["applicants"] + :users ["reviewers"] + :models ["openai/gpt-5"] + :human-oversight "Required before use"} + (phase "Review") + (checkpoint :evidence #t) + {:status :success})) + (employment + (defworkflow package-employment-assist + "require employment review" + {:policy policies/employment-assist + :owner "people-team" + :intended-purpose "Summarize job-relevant evidence" + :not-for "Ranking or rejection" + :affected-persons ["applicants"] + :human-oversight "A recruiter makes every decision"} + (phase "Review") + {:status :success}))) + (test/assert-equal :failed (get reviewed :status)) + (test/assert-equal :failed (get controlled :status)) + (test/assert-equal :failed (get oversight :status)) + (test/assert-equal :failed (get employment :status)))) -(test/run! - constructors-build-strict-policies - constructors-reject-empty-input +(test/run! constructors-build-strict-policies + constructors-reject_empty_or_extra_input + semantic-rule-builders-compose + configurable-input-and-evidence-controls + safe-code-and-support-profiles-are-strict + deny-only-profiles-compose-with-default-allow fixed-profiles-carry-stable-names - fixed-profiles-compile-in-workflows) + non-approval-profiles-compile-in-workflows + approval-profiles-fail-closed-without-review)