Skip to content

feat: add deterministic workflow policy guardrails and durable human approval gates - #143

Merged
HelgeSverre merged 31 commits into
mainfrom
feat/issue-50-approval
Aug 4, 2026
Merged

HelgeSverre merged 31 commits into
mainfrom
feat/issue-50-approval

Conversation

@HelgeSverre

@HelgeSverre HelgeSverre commented Jul 31, 2026 •

Copy link
Copy Markdown
Member

Summary

  • add durable human approval gates with immutable request sidecars and Ed25519-signed, compare-and-set decisions
  • support terminal approve/reject/leave-pending prompts plus durable approval-keygen, approvals, approve, and reject CLI workflows
  • add loopback-only approve/reject controls to the workflow viewer using the same signed sidecar protocol, with inspect-only fallback when no matching authority is present
  • bind approvals to immutable host inputs and the exact snapshotted entry/import/package dependency bytes used for evaluation
  • reject unsafe gate placement statically and fail closed at runtime for aliases, nested workflows, detached tasks, cleanup, retry, timeout, and concurrency forms
  • harden the viewer against hostile journal content, DNS rebinding, cross-origin writes, and inline-script injection
  • secure durable approval storage on Unix and Windows, using private modes or protected per-user ACLs respectively
  • allow completion policies to require approval.applied before a run can report success
  • include validated approval summaries and authoritative sidecars in workflow evidence exports
  • add LSP definition/scope/document-symbol/semantic-token support, website syntax highlighting, operator documentation, and the website favicon in the viewer

Safety properties

  • decisions bind the run, workflow, full dependency revision, arguments, phase, gate key/occurrence, canonical subject digest, request timestamp/revision, and authority key
  • the matching private key never enters the interpreter; terminal mode drops the interpreter before using its ephemeral signing key
  • workflow/approval is legal only as the direct callee, preventing aliases from escaping static placement analysis
  • step policies reject run metadata and completion evidence instead of silently evaluating incomplete evidence
  • approval failures are uncatchable and sticky across inherited tasks, so later protected forms cannot run after pending, rejected, malformed, cancelled, or misplaced gates
  • imports and loads execute from the exact bytes hashed during preflight; dynamic or macro-generated files outside that closure fail closed
  • sidecars use bounded no-follow reads, atomic hard-link publication, first-writer-wins conflict handling, and private Unix permissions or protected Windows ACLs
  • approval subjects use a type-preserving canonical encoding; mutable/runtime values are rejected and raw subjects are never persisted
  • terminal-visible text and copyable resume guidance escape control/bidi characters while preserving the exact original file, args, run directory, and public-key inputs
  • approval-enabled web serving refuses non-loopback binds; Host, Origin, and fetch metadata are validated before writes
  • viewer output is context-escaped and served with a nonce-based CSP, frame denial, MIME sniffing protection, and restrictive browser policies
  • evidence export validates request digests and decision signatures before including approval summaries or sidecar hashes

Operator workflow

sema workflow approval-keygen --private-key-file approval.private --public-key-file approval.public
sema workflow run release.sema --approval-mode pause --approval-public-key-file approval.public
sema workflow approvals <run-id> --json
sema workflow approve <run-id> <approval-id> --signing-key-file approval.private
sema workflow run release.sema --resume <run-id> --approval-mode pause --approval-public-key-file approval.public
sema workflow export <run-id>

On a real terminal, approval mode auto prompts interactively and resumes in a fresh interpreter. EOF, quit, or Ctrl-C leaves the durable request pending.

For web decisions, run with --view and --approval-signing-key-file, or start workflow view with 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:

(defpolicy human-reviewed
  {:completion {:require-events [:approval.applied]}})

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-policies v0.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

  • 24/24 sema-policy tests passed
  • 64/64 workflow-suite tests passed
  • 9/9 approval CLI tests passed
  • 2/2 approval viewer tests passed
  • focused evidence-export tests passed, including tampered-decision rejection
  • strict Clippy and Rust formatting checks passed
  • Windows GNU cross-check passed
  • Linux cross-check reached the missing local x86_64-linux-gnu-gcc toolchain, not a source error
  • full earlier branch verification covered 7,439 workspace tests, 56 LSP tests, release examples, web-runtime freshness, Playwright viewer tests, generated docs, and editor syntax repositories

Platform behavior

  • Unix uses private directory/file modes and no-follow checks for approval state and keys
  • Windows uses protected DACLs for the current user and SYSTEM, rejects reparse points, and applies ACLs through the opened handle before reading private keys
  • unknown platforms fail closed when secure durable storage cannot be established
  • the loopback viewer, signed decision protocol, terminal workflow, inspection commands, completion policies, and evidence exporter avoid Unix-only path or shell assumptions

Related syntax PRs

Stack

This remains a draft stacked on #142.

Part of #50.

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.
# 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
@HelgeSverre
HelgeSverre changed the base branch from feat/issue-50-policy to main August 2, 2026 22:02
@HelgeSverre HelgeSverre changed the title feat(workflow): add durable human approval gates feat: add deterministic workflow policy guardrails and durable human approval gates Aug 2, 2026
- 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.
@HelgeSverre
HelgeSverre marked this pull request as ready for review August 4, 2026 07:35
@HelgeSverre
HelgeSverre merged commit 6a2baa9 into main Aug 4, 2026
6 checks passed
@HelgeSverre
HelgeSverre deleted the feat/issue-50-approval branch August 4, 2026 17:36
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