feat: add deterministic workflow policy guardrails and durable human approval gates - #143
Merged
Merged
Conversation
The approval panel gave a decided request the same full-size card as a pending one and rendered both the approve-comment and the reject-reason field at once, so two cards used about 700px above the body grid and the operator had to guess which note field applied. The four-column form used align-items:end with an extra help span inside one cell, which floated the REJECTION REASON label far above ACTOR. The 1s live poll also rebuilt the rows with innerHTML unconditionally, discarding text typed into the form. Show the full card only for pending requests and sort them first; collapse a decided request to one ledger line, the same idiom as the auth rows. Keep one note field: Reject swaps in the required reason field plus Confirm and Cancel, so an irreversible first-writer-wins decision takes two steps. Send only the field belonging to the chosen action, so a note typed before switching to reject is not attached. Rebuild the rows only when the id, status, or busy set changes, so a poll cannot discard operator input. Also: approve now uses the success green (gold means running in this palette), the preview is capped at 11em with scroll so one long preview cannot push the run view off screen, decisions read "approved"/"rejected", the gate key is an h3, and status glyphs are aria-hidden. Verified in Chrome against a gated release-pipeline run: approve, reject, empty-reason validation, inspect-only with no signing key, and 500/720/ 1440px. workflow_view_approval_test 2/2, all 33 workflow_view tests, clippy and fmt clean.
This was referenced Jul 31, 2026
# Conflicts: # crates/sema/web-runtime.lock
…ollision - walk_approval_placement: recurse into vectors and maps (was skipping approval inside [...], let-bindings, and map values) - check_first_class_approval: catch short name 'approval' alias, not just 'workflow/approval' - detect (apply approval ...) inside restricted contexts - approval.rs: add thread-local nonce to tmp filename to prevent same-process nanosecond collision in publish_json_once - sema-policy: replace current_dir() with canonicalize() in absolute_lexical, removing CWD-dependent non-determinism - add tests for vector, let-binding, apply, and short-name alias edge cases
- Make model_action()/tool_action() return Option<T> to honestly signal absent sections (silent allow was misleading callers). Update check_active_policies to accept Option<T> actions. - Prevent eval bypass of policy/without by documenting sandbox requirement in workflows.md. - Add TOCTOU guards in ensure_request/read_resolution: when a sidecar is deleted between stat and read, return Pending or InvalidData instead of propagating a raw I/O error. - Add O_NOFOLLOW to public key file open (matching private key). - Document streaming output policy tradeoff (tokens consumed before block), policy/without sandbox requirement, and macro-expanded import limitation in workflows.md. - Flag intellij-sema#1 (missing 14 builtins) and emacs-sema#1 (incorrect policy/without indentation) on GitHub.
The last two approval fix commits edited workflow_check.rs and main.rs without regenerating the inventory map, so the committed file:line entries went stale and unified_runtime_inventory_mapping_covers_exact_current_matches failed. Regenerated with --write-mapping and carried the existing classifications (C12, H02) across; the match content is unchanged and the class histogram is identical to before.
SubjectRule::matches treated every constraint error as "does not match".
A constraint returns an error both when the value was compared and is not
covered, and when the value could not be compared at all. Because the deny
list and the allow list shared that predicate, a subject a deny rule could
not read was reported as unmatched and then allowed by :default :allow.
Concretely, a domain selector defaults :schemes to ["https"], so
{:subjects {:default :allow
:deny [{:kind :network-request :domains ["evil.example.com"]}]}}
denied https://evil.example.com but allowed http://evil.example.com — the
scheme error read as "no match". The same escape applied to :paths deny
rules for absolute and root-escaping paths, which normalize_policy_path
rejects: {:kind :file-write :paths ["**"]} denied src/x.rs but allowed
/tmp/evil.txt and ../evil.txt.
Constraint checks now return ConstraintMiss, which records whether the
comparison actually ran. A deny rule counts an un-evaluatable subject as a
match and an allow rule counts it as a non-match, so both fail closed. A
subject that was compared and is not covered stays a non-match for both, so
a deny rule naming one host does not deny every other host. The tools
section is an allowlist where every miss denies, so its behavior is
unchanged.
Both cases are pinned by new tests that fail without this change.
phase.ended.status was the one attacker-influenced string in the viewer
that reached innerHTML unescaped. An agent can set it: json_to_value turns
LLM JSON object keys into keywords, so a returned {"status": "..."} flows
through success_envelope and envelope_status into phase.ended.status
verbatim. The viewer auto-selects the last started phase on load, so the
markup renders without a click.
The branch also adds a script-src CSP with no unsafe-inline, which stops
script execution, but the field should be escaped like every other rendered
value.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
approval.appliedbefore a run can report successSafety properties
workflow/approvalis legal only as the direct callee, preventing aliases from escaping static placement analysisOperator workflow
On a real terminal, approval mode
autoprompts interactively and resumes in a fresh interpreter. EOF, quit, or Ctrl-C leaves the durable request pending.For web decisions, run with
--viewand--approval-signing-key-file, or startworkflow viewwith the matching signing key. The viewer signs first-writer-wins decisions host-side and never exposes private key material. A web decision does not silently resume execution; the operator resumes the exact run explicitly.A workflow can require applied review evidence with:
This is run-wide evidence. Keep the explicit gate immediately before the protected action and bind its subject to that action's stable identity.
Standard policy pack
The first-party
sema-policiesv0.1.0 release remains available from the package registry. The expanded v0.2.0 pack is proposed in sema-lisp/packages#1 and adds semantic rule builders, safe-code and customer-support profiles, output audits, human-review/change-control profiles, and AI oversight/employment baselines.The pack is intentionally separate from core. It depends on this approval/evidence work and the policy runtime in #142.
Verification
sema-policytests passedx86_64-linux-gnu-gcctoolchain, not a source errorPlatform behavior
Related syntax PRs
Stack
This remains a draft stacked on #142.
Part of #50.