From d2a2bf7b2dc217725356ee7e6e2e78953335ad31 Mon Sep 17 00:00:00 2001 From: "Festus B. Jejelowo" Date: Mon, 21 Sep 2026 19:40:13 +0200 Subject: [PATCH] Govern v2: the Marketplace address delegates to the gate in decionis/agent-safe-pipeline The gate now lives in decionis/agent-safe-pipeline under govern/, the reference implementation of the Decionis execution contract: one Go binary for GitHub Actions, GitLab CI, Jenkins and any other runner, which captures a step as an execution intent, asks Decionis through enforce-and-bind, runs the command only on a claimed grant, and finalizes the outcome into the Decision Dossier; no local policy engine, tenant-id in place of org-id and workflow-key. This repository stays the Marketplace address: action.yml carries the listing's metadata (name, branding, the v2 inputs and outputs, generated from the gate's own action.yml) and delegates every input and output to decionis/agent-safe-pipeline/govern at a pinned commit, which downloads the release binary its commit names and verifies it before running, or builds the same bytes from the commit when no archive exists yet. To move to a newer gate, move the pin. The v1 node20 action (src/, its tests, the onboarding installer, the execution-grant recipes) leaves main; it stays where its tags point, v1.9.3 the last, and `uses: decionis/govern@v1` keeps resolving to it. The examples are copies of the gate's v2 recipes; README, llms.txt, SUPPORT.md and SECURITY.md say where the gate lives, what changed from v1, and where to report what. The api-key description no longer carries an expression, as f7bc51d taught: GitHub evaluates it. After this merges: tag v2.0.0 on the merge commit, and the advance-major-tag workflow points v2 at it so `uses: decionis/govern@v2` resolves. Co-authored-by: Claude Opus 5 Signed-off-by: Festus B. Jejelowo --- README.md | 332 ++------- SECURITY.md | 21 +- SUPPORT.md | 6 +- action.yml | 297 ++++---- assets/pr-comment.svg | 47 -- examples/DECIONIS_POLICY.devops.md | 140 +--- examples/DECIONIS_POLICY.md | 101 +-- examples/auto-merge-dependabot.yml | 44 +- examples/gate-ai-agent-pr.yml | 62 +- examples/gate-deploy-azure.yml | 66 -- examples/gate-deploy-broker.yml | 51 -- examples/gate-deploy-gcp-wif.yml | 68 -- examples/gate-deploy.yml | 48 +- examples/gate-pr-comment.yml | 42 +- examples/gate-release.yml | 53 +- examples/gate-terraform.yml | 69 +- install.sh | 469 ------------ llms.txt | 33 +- package.json | 32 - src/api-client.mjs | 122 ---- src/index.mjs | 1076 ---------------------------- src/policy-engine.mjs | 646 ----------------- test/helpers.test.mjs | 310 -------- test/installer.test.mjs | 272 ------- test/integration.test.mjs | 344 --------- test/orchestration.test.mjs | 197 ----- test/policy-engine.test.mjs | 554 -------------- 27 files changed, 430 insertions(+), 5072 deletions(-) delete mode 100644 assets/pr-comment.svg delete mode 100644 examples/gate-deploy-azure.yml delete mode 100644 examples/gate-deploy-broker.yml delete mode 100644 examples/gate-deploy-gcp-wif.yml delete mode 100755 install.sh delete mode 100644 package.json delete mode 100644 src/api-client.mjs delete mode 100644 src/index.mjs delete mode 100644 src/policy-engine.mjs delete mode 100644 test/helpers.test.mjs delete mode 100644 test/installer.test.mjs delete mode 100644 test/integration.test.mjs delete mode 100644 test/orchestration.test.mjs delete mode 100644 test/policy-engine.test.mjs diff --git a/README.md b/README.md index d1ea3ff..16992ba 100644 --- a/README.md +++ b/README.md @@ -1,297 +1,53 @@ -# πŸ›‘οΈ Decionis Action Gate +# Decionis Govern -**Stop unauthorized deploys, migrations, and infra changes before they reach production β€” whether a teammate or an AI agent triggered them.** +**One verdict before a workflow step runs β€” a deploy, a migration, an infrastructure change, an +agent's action β€” with a signed Decision Dossier of it.** [![Marketplace](https://img.shields.io/github/v/release/decionis/govern?label=marketplace&logo=githubactions&logoColor=white&color=6D28D9)](https://github.com/marketplace/actions/decionis-action-gate) -[![Governed by Decionis](https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white)](https://github.com/decionis/govern) +[![Governed by Decionis](https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white)](https://github.com/decionis/agent-safe-pipeline/tree/master/govern) [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](./LICENSE) -Add **one step** to your workflow, and every deploy, migration, infra change, or AI-generated PR is checked against **your** policy β€” then **allowed, blocked, or escalated before it runs**, with a signed, verifiable record of every decision. - -**Try it in 30 seconds, risk-free.** Start in shadow mode: it records what _would_ be governed without ever failing a build β€” so you see the value before you enforce anything. - ---- - -## The question every pipeline now faces - -AI coding agents β€” **Claude Code, Copilot, Cursor, Codex, OpenHands** β€” now open PRs, edit workflows, write migrations, and trigger deploys. The hard question is no longer _"who wrote this code?"_ It's: - -> **Should this action be allowed to execute?** - -That's the Action Gate. - -## Why Decionis - -- **One verdict per governed action** β€” `allow` / `block` / `restrain` / `escalate`, evaluated against _your_ policy before anything runs. -- **Deterministic policy you own** β€” versioned rules, committed as `DECIONIS_POLICY.md` or connected from where policy already lives; every verdict cites the version that applied. -- **Microsecond verdicts** β€” the committed rules block is evaluated **in-process** by a local engine that mirrors the server, so deterministic verdicts act in ~1ms while the API notarizes off the critical path. Shadow mode is **speculative**: your command starts immediately, adding ~zero latency. -- **Cryptographic proof** β€” each decision is a signed, public-verifiable Decision Dossier: audit-ready evidence of what was authorized and why, pinned to the exact policy revision (`sha256`). -- **Zero-friction adoption** β€” start in shadow mode in 30 seconds; it never fails a build until you choose to enforce. - -## Set it up in one step - -Pick whichever is fastest β€” both drop a **shadow-mode** gate in, so nothing fails your build on day one: - -- **New repo:** [**Start from the examples β†’**](https://github.com/decionis/govern/tree/main/examples) β€” ready-wired gate workflows (deploy, Terraform, AI-agent PRs, Dependabot auto-merge) + a starter `DECIONIS_POLICY.md`. -- **Existing repo, one command:** - - ```bash - curl -fsSL https://decionis.com/govern/install.sh | sh - ``` - - Writes a shadow-mode workflow + a starter `DECIONIS_POLICY.md` (no secrets touched, idempotent). Flags go after `sh -s --`, and the hosted one-liner takes two: - - | Flag | Effect | - | --------- | ---------------------------------------------------------------------------------------------------------- | - | `--pr` | Branch (`feature/add-decionis-governance`), commit, push, and open the onboarding PR (`gh` CLI, or prints the compare URL). | - | `--force` | Overwrite files that already exist. | - - The fuller installer ships in this repo ([`install.sh`](./install.sh)) and adds `--inject` (insert an **observe-only** shadow step at the top of every job in your existing workflows β€” insert-only, `continue-on-error: true`, skips anything unsafe), `--dry-run`, `--mode`, `--org-id`, and `--workflow-key`. Run that copy directly: - - ```bash - # The full viral onboarding: gate every workflow, open the PR - curl -fsSL https://raw.githubusercontent.com/decionis/govern/v1/install.sh | sh -s -- --pr --inject - ``` - -Then add your `DECIONIS_API_KEY` / `DECIONIS_ORG_ID` secrets β€” [free keys here](https://decionis.com/quickstart?source=github_action). - -## 30-second quickstart - -**Wrap the command you want to govern.** Decionis runs it _through_ the gate, so it can't execute without an authorizing verdict: - -```yaml -- uses: decionis/govern@v1 - with: - api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval - action: production-deploy - run: ./deploy.sh # ← Decionis runs this ONLY if it authorizes the action -``` - -On `allow` the command runs. On `block`/`escalate` it **never runs** and the step fails. Try it risk-free with `mode: shadow` β€” the command still runs, but Decionis only records the verdict (never fails the build): - ```yaml -- uses: decionis/govern@v1 +- uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval - action: production-deploy - run: ./deploy.sh - mode: shadow # observe-only; command starts instantly, verdict records in the background - comment-pr: "true" # posts the verdict + verify link on the PR -``` - -Need keys? Create them free at **[decionis.com/quickstart?source=github_action](https://decionis.com/quickstart?source=github_action)** β€” no card, no call. - -### Cryptographic proof, verified at the target - -Set `request-grant: true` and every authorized run carries a short-lived, single-use, signed **Execution Grant** β€” `DECIONIS_EXECUTION_GRANT` in the command's environment. Your deploy target verifies it against Decionis's public keys before it acts, so authorization is proven where the action actually happens. - -```yaml -- uses: decionis/govern@v1 - with: - workflow-key: github_deploy_approval - action: production-deploy - request-grant: true - grant-audience: prod-us-east - run: ./deploy.sh # presents $DECIONIS_EXECUTION_GRANT to the target -``` - -### Zero standing credentials - -Take secrets out of CI entirely. Register a target's credential with Decionis once, and it's released only in exchange for an authorized run β€” your pipeline holds nothing to leak. See [`examples/gate-deploy-broker.yml`](./examples/gate-deploy-broker.yml), or federate **GCP Workload Identity Federation** / **Azure** so the cloud issues credentials only for a Decionis-authorized deploy. - ---- - -## What you get - -### 1. 🚦 A verdict before anything runs - -One verdict before execution β€” **`allow`**, **`block`**, or **`escalate`**. Composable into any later step via `steps..outputs.decision`. - -### 2. 🟣 A zero-risk, zero-latency way to start - -`mode: shadow` shows exactly what would be governed, without ever failing a build β€” and it's **speculative**: your `run` command starts immediately while the verdict resolves in the background, so the gate adds ~zero wall-clock time. The step's exit code is exactly your command's; evaluation problems (API down, secrets not configured yet) surface as notices, never failures. Enforce when you're ready β€” one line. - -### ⚑ Local policy engine - -The ```` ```decionis ```` rules block in your `DECIONIS_POLICY.md` is evaluated **in-process** by a local engine that faithfully mirrors the platform evaluator (same operators, same coercions, same first-match ordering). A deterministic `allow`/`block` from an explicitly matched committed rule acts in **microseconds**; the API call becomes an async notarization off the critical path, so the signed dossier, verify URL, and badge still arrive. The log shows the speedup: - -``` -⚑ Decionis local verdict 'block' via rule "Block deploys during a change freeze" in 0.41ms β€” API roundtrip moved off the critical path. -``` - -Control it with `local-eval`: - -- **`auto`** (default) β€” local verdicts act instantly; the API notarizes in the background. If the API disagrees (org-level policy can add rules), you get a `::warning::` and `verdict-mismatch=true`. -- **`strict`** β€” deterministic local verdicts skip the network entirely (offline-capable; those runs mint no dossier). -- **`off`** β€” v1.8 behavior: every verdict comes from the blocking API call. - -The engine **never guesses**: matched `escalate`/`restrain`, no matching rule, unknown operators, malformed/YAML policies, and rules that depend on server-side state always fall back to the API. `request-grant: true` always uses the blocking path (the signed grant must exist before your command's environment is built). - -### 3. 🧾 Proof you can hand an auditor - -Every verdict produces a signed, public-verifiable [Decision Dossier](https://decionis.com/dossier-example?source=github_action): **why** it happened, **who** approved it, **which policy** applied, and the **risk**. The verify link unfurls as an OG card in Slack / Teams / LinkedIn β€” paste it in an incident, a change ticket, or an audit and it holds up. - ---- - -## Your policy β€” what the gate evaluates against - -Every gated action is evaluated against **your organization's policy**: the rules that decide whether an action is **allowed, blocked, restrained, or escalated**. You own those rules. - -- **Zero config to start.** Out of the box, Decionis applies a built-in **default policy pack** for your workflow's vertical (core, finance, hospitality, and more), so the 30-second quickstart governs immediately β€” no policy authoring required. -- **Make it yours, and it's versioned.** Add or update rules at any time; Decionis **versions every change**, and each verdict's Decision Dossier records exactly **which policy version applied** β€” so an audit can trace any decision back to the rule that made it. -- **Bring policy from where it already lives.** Build rules dynamically, **upload or paste** a policy file, or **connect the source of truth** and Decionis keeps the encoded policy in sync β€” **Google Drive, GitHub, Jira, Confluence, Notion, or SAP.** - -The result: the gate isn't a generic check β€” it enforces _your_ rules, kept current with how your organization actually documents them. - -### Policy as a file: `DECIONIS_POLICY.md` - -Keep policy where developers already work β€” in the repo, in Markdown, reviewed by PR. Drop a **`DECIONIS_POLICY.md`** at your repo root and the action reads it, **content-hashes it, and injects it into every decision** β€” so the gate governs against your repo's policy and the signed Decision Dossier records exactly which policy (by `sha256`) applied. Change the file, get a new recorded revision. No dashboard step. - -```yaml -- uses: decionis/govern@v1 - with: - workflow-key: github_deploy_approval + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} action: production-deploy - # policy-file: DECIONIS_POLICY.md # default; set "" to disable - run: ./deploy.sh -``` - -Outputs `policy-sha256` + `policy-path`. A missing/unreadable file never fails the gate. **Two different things get called "enforcement":** the committed rules block is evaluated by the local engine on every run (`local-eval: auto`, on by default) and that verdict gates this step, while publishing those rules as the org's **active policy bundle** for this `workflow-key` is a separate opt-in via `policy-enforce: true`. At the default, your file is recorded and version-pinned on the dossier but does not change server-side enforcement. See the annotated [example policy](./examples/DECIONIS_POLICY.md) to copy β€” or the [DevOps/CI example](./examples/DECIONIS_POLICY.devops.md) (escalate infra destroys, restrain applies + release deploys, block during change freeze). For an **org-wide** policy, point a Git source connector at your `.github` repo's `DECIONIS_POLICY.md`. - ---- - -## Govern AI-generated changes - -When an AI agent opens a PR or triggers a deploy, gate it **before** it merges or ships: - -```yaml -- uses: decionis/govern@v1 - id: gate - with: - api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: ai_change_gate - action: ai-generated-pr - comment-pr: "true" - payload: | - { "author": "${{ github.actor }}", "agent_generated": true } -``` - -See [`examples/gate-ai-agent-pr.yml`](./examples/gate-ai-agent-pr.yml) for the full recipe (auto-detects agent authorship and requires a human verdict on risky changes). - -## Also governs - -- **Deployments** β€” production releases, blue/green cutovers -- **Infrastructure** β€” `terraform apply`, Pulumi, CDK -- **Data** β€” database migrations, destructive jobs -- **Privileged workflows** β€” release pipelines, secrets rotation, IAM changes - -## What reviewers see on the PR - -A single, **self-updating** comment β€” it stays current on every run: - -![Example Decionis PR comment β€” Blocked verdict with a signed verify link](./assets/pr-comment.svg) - -## πŸ“Œ Add the badge - -Show your pipeline is governed β€” and let other devs discover the gate. Also emitted as the `badge-markdown` output, pointing at the live verify URL **pinned to your policy revision** (`…&policy=sha256:`), so the badge cryptographically binds the decision to the exact committed policy state: - -```markdown -[![Governed by Decionis](https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white)](https://github.com/decionis/govern) -``` - -[![Governed by Decionis](https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white)](https://github.com/decionis/govern) - ---- - -## Recipes - -Copy-paste workflows in [`examples/`](./examples/): - -| Recipe | What it gates | -| ------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| [`gate-ai-agent-pr.yml`](./examples/gate-ai-agent-pr.yml) | AI-generated PRs (Claude Code, Copilot, Cursor…) before merge. | -| [`gate-deploy.yml`](./examples/gate-deploy.yml) | A production deploy on a `block` verdict (enforce). | -| [`gate-terraform.yml`](./examples/gate-terraform.yml) | `terraform apply` on the plan's blast radius. | -| [`gate-release.yml`](./examples/gate-release.yml) | A verdict before a tagged release ships. | -| [`auto-merge-dependabot.yml`](./examples/auto-merge-dependabot.yml) | Auto-merge a dependency PR only when the verdict is `allow`. | -| [`gate-pr-comment.yml`](./examples/gate-pr-comment.yml) | Shadow-mode evaluator that comments without failing the build. | -| [`gate-deploy-broker.yml`](./examples/gate-deploy-broker.yml) | Release deploy credentials only for an authorized run β€” no standing secrets. | -| [`gate-deploy-gcp-wif.yml`](./examples/gate-deploy-gcp-wif.yml) | GCP Workload Identity Federation β€” the cloud issues credentials only on `allow`. | -| [`gate-deploy-azure.yml`](./examples/gate-deploy-azure.yml) | Azure federated credentials β€” the cloud issues credentials only on `allow`. | - -## Inputs - -| Input | Required | Default | Description | -| -------------------- | -------- | ----------------------------- | ------------------------------------------------------------------ | -| `api-key` | yes | β€” | Decionis API key with `protocol:evaluate` scope. Pass as a secret. | -| `org-id` | yes | β€” | Decionis org id (UUID). | -| `workflow-key` | yes | β€” | Workflow key registered in Decionis policy. | -| `action` | no | β€” | Short label for what's being gated (e.g. `production-deploy`). | -| `run` | no | β€” | Command Decionis runs **only if authorized** (the enforcing path). | -| `shell` | no | `bash` | Shell for `run` β€” `bash` or `sh`. | -| `request-grant` | no | `false` | Issue a signed Execution Grant on an authorizing verdict. | -| `grant-audience` | no | β€” | Bind the grant to a target/env id (e.g. `prod-us-east`). | -| `payload` | no | _built from workflow context_ | JSON object describing the action being gated. | -| `fail-on` | no | `block` | Which verdicts fail the step β€” `block` / `escalate` / `block_or_escalate` / `never`. **Advisory path only:** when `run` is set, any non-authorizing verdict blocks the command and fails the step regardless of this value. | -| `mode` | no | `enforce` | `enforce` or `shadow`. Shadow never fails the step and starts `run` commands immediately (speculative). | -| `local-eval` | no | `auto` | Local policy engine: `auto` (act locally, notarize async), `strict` (offline), `off` (v1.8 blocking API). | -| `comment-pr` | no | `false` | Post (and update in place) the verdict as a PR comment. | -| `show-attribution` | no | `true` | Include the "Governed by Decionis" footer on the PR comment. | -| `api-base-url` | no | `https://api.decionis.com` | Override for staging / self-host. | -| `site-base-url` | no | `https://decionis.com` | Override for staging / self-host. | -| `request-timeout-ms` | no | `20000` | Timeout for the evaluate-decision call. | -| `policy-file` | no | `DECIONIS_POLICY.md` | Repo-relative policy file injected into the decision and content-hashed. Set to `""` to disable. | -| `policy-enforce` | no | `false` | Compile the file's ` ```decionis ` rules block into the **active enforced** bundle for this org + workflow-key. Left `false`, the file is recorded and versioned but does not auto-enforce. | - -## Outputs - -| Output | Description | -| ------------------ | ----------------------------------------------------------------------------- | -| `decision` | `allow` / `block` / `escalate` / `restrain` / `review` (API outcomes are normalized, e.g. `APPROVE`β†’`allow`). | -| `decision-source` | `local` (deterministic committed-rule verdict) or `api`. | -| `verdict-mismatch` | `true` when a local verdict acted but the notarizing API verdict differed. | -| `dossier-id` | Signed Decision Dossier id for this evaluation. | -| `verify-url` | Public verify URL (`?sig=` for OG unfurls, `&policy=sha256:…` pinning the policy revision). | -| `policy-version` | Policy version (string) that produced the verdict. | -| `policy-sha256` | sha256 of the injected policy file β€” the content-addressed version handle. | -| `policy-path` | Repo-relative path of the injected policy file, if one was found. | -| `policy-enforced` | `true` when `policy-enforce` was set and a policy file was found. | -| `reason-code` | Stable reason code (string), if returned. | -| `badge-markdown` | Ready-to-paste "Governed by Decionis" badge linking to the live verify URL. | -| `executed` | `true` if a `run` command was authorized and executed, `false` if blocked. | -| `execution-grant` | Signed Execution Grant (EdDSA JWT) when `request-grant: true` and authorized. | -| `grant-expires-at` | ISO timestamp when the Execution Grant expires. | - -## Permissions - -Default (`contents: read`) is enough. To enable `comment-pr: 'true'`: - -```yaml -permissions: - contents: read - pull-requests: write -``` - -## How it works - -1. The repo's `DECIONIS_POLICY.md` is read, sha256-hashed, and its ```` ```decionis ```` rules block is evaluated **in-process** (microseconds) by a local engine that mirrors the platform evaluator. -2. A deterministic local `allow`/`block` acts immediately; `POST /v1/protocol/evaluate-decision` runs as an async notarization that mints the signed dossier and cross-checks the verdict. Everything the engine can't decide falls back to the blocking API call, exactly like v1.8. -3. In `mode: shadow` with a `run` command, the command starts **first** and the entire evaluation runs in the background; after the command exits, the step waits a bounded grace window (≀10s) for the verdict, then finishes with the command's exit code. -4. Inputs are echoed into the dossier so you can audit exactly what produced it. In `enforce` mode a non-200 from the API (with no local verdict) fails the step with the status β€” no silent green builds. - -Every run logs a timing line so the speedups are visible, e.g. `Decionis timing β€” command started +3ms Β· API verdict 'allow' in 1840ms +1846ms Β· command exited code 0 +61.2s`. - -### Changed in v1.9.0 - -- **Shadow mode never fails the step, period.** Previously an API error failed the step even in shadow; now all gate/evaluation failures in shadow are `::notice::` lines and the exit code is your command's (or 0). Shadow also no longer requires credentials β€” unconfigured gates are inert. -- **`local-eval: auto` is on by default.** Repos with a ```` ```decionis ```` rules block get local verdicts + async notarization; repos without one see no change in decision flow. Set `local-eval: off` for strict v1.8 behavior. -- **API outcomes are normalized** (`APPROVE`β†’`allow`, `REJECT`β†’`block`, `REQUIRE_REVIEW`/`REVIEW`β†’`review`) so `run` gating and `fail-on` work against the current Decionis API vocabulary. - ---- - -Built by [Decionis](https://decionis.com?source=github_action_readme) Β· [Quickstart](https://decionis.com/quickstart?source=github_action) Β· [Dossier example](https://decionis.com/dossier-example?source=github_action) Β· Apache-2.0 licensed + environment: production + run: ./scripts/deploy.sh # runs only on an ALLOW whose grant this step claimed +``` + +The step becomes an execution intent, Decionis decides on exactly that intent, the command runs +only on an `ALLOW` whose single-use grant the step claimed first, and what happened is finalized +into a signed Decision Dossier. `BLOCK` ends the step with the command never started; `ESCALATE` +can hold it while Decionis orchestrates a person's approval; `mode: shadow` starts the command at +once and records the verdict beside it, never failing a build. Nothing is decided locally: a +Decionis that cannot be reached is a refusal, and the command does not run. + +## Where the gate lives + +This repository is the Marketplace address. The gate itself β€” one Go binary for GitHub Actions, +GitLab CI, Jenkins and any other runner, speaking the Decionis execution contract the +[AgentSafe runtime](https://github.com/decionis/agent-safe-pipeline) speaks β€” lives in +[`decionis/agent-safe-pipeline` under `govern/`](https://github.com/decionis/agent-safe-pipeline/tree/master/govern), +with its tests, its releases and its page, [docs/govern.md](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/govern.md). +[`action.yml`](./action.yml) here carries the listing's metadata and delegates every input and +output to that action at a pinned commit, which downloads the release binary its commit names and +verifies it before running. + +- Every input, output, exit code and runner surface: [the gate's README](https://github.com/decionis/agent-safe-pipeline/blob/master/govern/README.md). +- The recipes in [`examples/`](./examples) are copies of the gate's: a deploy, `terraform apply` + on the plan's blast radius, a release held for the release manager, shadow comments on every + pull request, agent-authored pull requests, Dependabot auto-merge. +- Starting a repository: install the binary and run `govern init`, which writes a shadow-mode + workflow and a `DECIONIS_POLICY.md` and touches nothing else. + +## v1 + +`uses: decionis/govern@v1` is the earlier node20 action, which spoke the evaluate-decision API +with `org-id` and `workflow-key` inputs and evaluated a rules block locally. It stays where its +tags point ([v1.9.3](https://github.com/decionis/govern/tree/v1.9.3)) and is not developed +further; v2 speaks the execution contract, has no local policy engine, and takes `tenant-id` in +place of `org-id` and `workflow-key`. Keys and workspaces are the same. + +Built by [Decionis](https://decionis.com?source=govern_readme) Β· Apache-2.0 diff --git a/SECURITY.md b/SECURITY.md index d62da1e..037c485 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -Decionis takes the security of the **Decionis Action Gate** (`decionis/govern`) and the platform it talks to seriously. +Decionis takes the security of **Decionis Govern** (`decionis/govern`), the gate it delegates to, and the platform it talks to seriously. ## Reporting a vulnerability @@ -13,17 +13,18 @@ We acknowledge reports within **2 business days** and aim to provide a remediati ## Scope -- The Action source (`src/`, `action.yml`) in this repository. -- The Decionis APIs the Action calls (`api.decionis.com`), including the evaluate-decision, execution-grant, and OIDC endpoints. +- `action.yml` in this repository, which delegates to the gate at a pinned commit. +- The gate itself, `govern/` in [decionis/agent-safe-pipeline](https://github.com/decionis/agent-safe-pipeline), whose [security policy](https://github.com/decionis/agent-safe-pipeline/security/policy) covers it: report there for the binary, the installer, the release archives and the contract client. +- The Decionis APIs the gate calls (`api.decionis.com`): enforce-and-bind, the escalation status, claim-token and finalize-token. -## What the Action handles +## What the action handles -- Your `DECIONIS_API_KEY` is read only from the workflow input you provide and is sent solely to the configured `api-base-url` over HTTPS. It is never logged. -- Execution Grants are short-lived, single-use, and signed; they are verifiable against the published JWKS. -- The Action runs with zero third-party dependencies (Node 20 built-ins only), minimizing supply-chain surface. +- Your `DECIONIS_API_KEY` reaches the gate as an input and is sent solely to the configured `api-url` over HTTPS, as a bearer token. It is never logged and never given to the gated command. +- The execution grant a decision carries is claimed by the gate immediately before the command and never leaves it; the command receives the decision's identifiers and the authority's claim attestation. +- The pinned action downloads the release binary its commit names and verifies its SHA-256 before running it, or builds the same bytes from the pinned commit. ## More -- Security overview: https://decionis.com/security -- Privacy: https://decionis.com/privacy -- Terms: https://decionis.com/terms +- Security overview: +- Privacy: +- Terms: diff --git a/SUPPORT.md b/SUPPORT.md index 8471195..3d9f34c 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -1,11 +1,11 @@ # Support -Help with the **Decionis Action Gate** (`decionis/govern`). +Help with **Decionis Govern** (`decionis/govern`), the workflow gate. ## Get started - **Quickstart + free API keys:** https://decionis.com/quickstart?source=github_action -- **Action docs:** the [README](./README.md) and copy-paste workflows in [`examples/`](./examples/) +- **Action docs:** the [README](./README.md), copy-paste workflows in [`examples/`](./examples/), and [the gate's README](https://github.com/decionis/agent-safe-pipeline/blob/master/govern/README.md) - **GitHub Action page:** https://decionis.com/marketplace/github ## Get help @@ -13,7 +13,7 @@ Help with the **Decionis Action Gate** (`decionis/govern`). | Need | Where | | ----------------------------------- | ---------------------------------------------------------- | | Setup, policy, or verdict questions | **support@decionis.com** | -| A bug in the Action | [Open an issue](https://github.com/decionis/govern/issues) | +| A bug in the gate | [Open an issue](https://github.com/decionis/agent-safe-pipeline/issues) | | A suspected security issue | See [SECURITY.md](./SECURITY.md) β€” report privately | | API / platform status | https://decionis.com | diff --git a/action.yml b/action.yml index ca5cd6a..c5d916d 100644 --- a/action.yml +++ b/action.yml @@ -1,204 +1,225 @@ +# The name is the Marketplace listing's, kept so its address stays +# github.com/marketplace/actions/decionis-action-gate. name: "Decionis Action Gate" author: "Decionis" -description: "Runtime guardrail for autonomous AI agents & CI/CD β€” gate deploys, migrations & infra before they run, with signed proof." +description: "Govern: one Decionis verdict before a workflow step runs β€” a deploy, a migration, an infrastructure change, an agent's action β€” with a signed Decision Dossier of it." branding: icon: "shield" color: "purple" +# This is the Marketplace address of Govern. The gate itself lives in +# decionis/agent-safe-pipeline under govern/, the reference implementation of +# the Decionis execution contract; this action carries the listing's metadata +# and delegates every input and output to that action at a pinned commit. +# The pinned action downloads the release binary its commit names and +# verifies it before running, or builds the same bytes from the commit when +# no archive exists yet. To move to a newer gate, move the pin. + inputs: api-key: description: >- - Decionis API key with `protocol:evaluate` scope. Pass as a workflow secret - (`secrets.DECIONIS_API_KEY`). Never hard-code. - required: true - org-id: - description: "Decionis org id (UUID) the decision belongs to." - required: true - workflow-key: - description: >- - The workflow key registered in Decionis policy (e.g. - `github_deploy_approval`). Drives which policy bundle is evaluated. - required: true + Decionis API key for the workspace. Pass a secret (`secrets.DECIONIS_API_KEY`); never write it in the workflow β€” GitHub evaluates an expression even inside this description. + required: false + default: "" + tenant-id: + description: >- + The workspace (organization) the key belongs to, a UUID. Not a secret, but a repository variable keeps it in one place. + required: false + default: "" + mode: + description: >- + `enforce` (default): the command runs only on an ALLOW whose grant this step claimed. `shadow`: the command starts at once, the verdict is recorded beside it, and the step never fails for the gate's sake. + required: false + default: "enforce" action: description: >- - Short label for the action being gated (e.g. `production-deploy`, - `terraform-apply`, `ai-generated-pr`). Recorded on the dossier and shown - in the verdict. Optional β€” purely for readability and reporting. + The action's type as the record names it, e.g. `production-deploy`, `terraform-apply`, `database-migration` (lowercase, `[a-z][a-z0-9._:-]*`). + required: false + default: "workflow.step" + resource: + description: >- + What the action acts on, e.g. `service:api` or `env:production`. Default: the command. + required: false + default: "" + payload: + description: >- + A JSON object describing the action: the parameters the record keeps and policy reads (`{"service":"api","replicas":3}`). + required: false + default: "" + environment: + description: >- + The deployment environment the action lands in, e.g. `production`. required: false default: "" run: description: >- - Shell command(s) that Decionis executes ONLY if it authorizes the action. - The gated command runs through Decionis, so it executes on `allow` and is - held on block/escalate (the step then fails). In shadow mode the command - always runs while Decionis records the verdict. + The command to gate. It runs through this step, so it cannot run without an authorizing verdict; there is no `if:` to delete. required: false default: "" shell: - description: "Shell used to run the `run` command. `bash` (default) or `sh`." + description: >- + `bash` (default) or `sh`. required: false default: "bash" - request-grant: + fail-on: description: >- - When `true` and the verdict authorizes the action, request a short-lived, - single-use signed Execution Grant. It is exposed as the `execution-grant` - output and injected into the `run` command's environment as - `DECIONIS_EXECUTION_GRANT` so the deploy target can verify the - authorization before acting. + For a step without `run`: which verdicts fail it. `block` (default), `escalate`, `block_or_escalate`, `never`. required: false - default: "false" - grant-audience: + default: "block" + escalation: description: >- - Optional audience to bind the Execution Grant to (e.g. a target/env id - like `prod-us-east`). The target verifies the grant was minted for it. + `managed`: on an ESCALATE, Decionis orchestrates the approval and the step holds for it, at most until the intent expires (five minutes). `none` (default): an ESCALATE ends the step like `fail-on` says. required: false - default: "" - payload: + default: "none" + approver: description: >- - JSON string describing the action being gated. Free-form fields per the - workflow's policy schema (resource, actor, requested change, etc.). When - omitted, a minimal payload is built from the workflow context - (repo / ref / sha / event / actor / run id). + The principal who approves a managed escalation. required: false default: "" - fail-on: + approver-role: description: >- - Which verdicts cause this Action to fail the step. One of: - `block` (default), `escalate`, `block_or_escalate`, `never`. + Or the role that does, e.g. `RELEASE_MANAGER`. required: false - default: "block" - mode: + default: "" + policy-file: description: >- - `enforce` (default) or `shadow`. In shadow mode the Action records a - verdict but never fails the step β€” useful for week-one rollouts. Shadow - is also speculative: a `run` command starts immediately while the verdict - resolves in the background, so the gate adds ~zero latency; evaluation - failures (API errors, missing credentials) become notices, never step - failures, and the step's exit code is exactly the command's. + The repository's policy file, whose path and SHA-256 travel in the intent so the dossier records the exact revision. Default `DECIONIS_POLICY.md` (`.yaml`/`.yml` siblings are tried); `""` disables. required: false - default: "enforce" - comment-pr: + default: "DECIONIS_POLICY.md" + comment: description: >- - When `true` and this run was triggered by a pull_request event, post (and - update in place) the verdict as a PR comment with the dossier verify URL. - Requires `pull-requests: write` permission on the workflow. + `true`: post (and update in place) the verdict on the pull request. Needs `pull-requests: write`. required: false default: "false" show-attribution: description: >- - When `true` (default), the PR comment includes a small "Governed by - Decionis" footer linking back to the action. Set `false` for a bare - comment with no attribution. + `false` drops the footer from the comment. required: false default: "true" - api-base-url: - description: "Override the Decionis API base URL (for staging / self-host)." + api-url: + description: >- + The Decionis API base URL (https), for self-hosting. required: false default: "https://api.decionis.com" - site-base-url: - description: "Override the public site base URL (for staging / self-host)." - required: false - default: "https://decionis.com" - request-timeout-ms: + timeout: description: >- - Timeout for the evaluate-decision call, in milliseconds. Also budgets the - post-command grace window for background notarization (capped at 10s). + Per authority call, e.g. `20s`. required: false - default: "20000" - policy-file: + default: "20s" + intent-ttl: description: >- - Path (relative to the repo root) to a `DECIONIS_POLICY.md` policy file to - inject into the decision. The file is read, content-hashed, and carried - with the evaluation so the gate governs against β€” and the Decision Dossier - records β€” your repo's policy. The sha256 is the version handle: change the - file, get a new recorded revision. Set to an empty string to disable. - A missing or unreadable file never fails the gate. A YAML policy file is - also accepted β€” with the default path, `DECIONIS_POLICY.yaml` / `.yml` - siblings are tried too. + How long the intent stays decidable; at most `5m`. required: false - default: "DECIONIS_POLICY.md" - policy-enforce: + default: "5m" + report: description: >- - When `true`, the structured ```decionis``` rules block in the policy file - is compiled into an ACTIVE enforced policy bundle for this org + - workflow-key (true GitOps β€” committing the file changes enforcement, no - dashboard). Idempotent by content hash. Default `false` (the file is - recorded/versioned but not auto-enforcing). Prose without a rules block is - never enforced. + Where to write the JSON record of the run; empty for none. required: false - default: "false" - local-eval: - description: >- - Local policy engine mode. `auto` (default): the ```decionis``` rules - block in your policy file is evaluated in-process (microseconds); a - deterministic allow/block from an explicitly matched rule acts - immediately and the API call becomes an async notarization off the - critical path β€” the signed dossier, verify URL, and badge still arrive, - and a disagreeing API verdict sets `verdict-mismatch=true` with a - warning. `strict`: deterministic local verdicts skip the network - entirely (offline-capable; no dossier for those runs). `off`: v1.8 - behavior β€” every verdict comes from the blocking API call. - Escalate/restrain matches, no-match, unknown operators, malformed or - YAML policies, and rules that depend on server-side state ALWAYS fall - back to the API. `request-grant: true` always forces the blocking path. - required: false - default: "auto" + default: "" outputs: decision: description: >- - Verdict β€” one of: allow, block, escalate, restrain, review (the API's - REVIEW outcome). API outcomes are normalized (APPROVEβ†’allow, - REJECTβ†’block, REQUIRE_REVIEWβ†’review). - decision-source: - description: >- - Where the acting verdict came from β€” `local` (deterministic rule from - the repo's policy file, evaluated in-process) or `api`. - verdict-mismatch: + `ALLOW`, `ESCALATE` or `BLOCK`; empty when the authority was not asked. + value: ${{ steps.gate.outputs.decision }} + decision-id: description: >- - "true" when a deterministic local verdict acted but the notarizing API - call returned a different verdict (org-level policy may add rules). - Emitted with a `::warning::` β€” the signed dossier records the API - verdict. + The authority's decision id. + value: ${{ steps.gate.outputs.decision-id }} dossier-id: - description: "Signed Decision Dossier id for this evaluation." + description: >- + The signed Decision Dossier's id. + value: ${{ steps.gate.outputs.dossier-id }} + dossier-url: + description: >- + The authenticated path of the dossier. + value: ${{ steps.gate.outputs.dossier-url }} verify-url: description: >- - Public verify URL with `?sig=` β€” opens the branded verification page; - unfurls with the OG card in Slack / Teams / LinkedIn / X. + A page anyone can open to verify the decision, when the authority attached one. + value: ${{ steps.gate.outputs.verify-url }} + intent-id: + description: >- + The intent's id (the grant issuance boundary). + value: ${{ steps.gate.outputs.intent-id }} + intent-hash: + description: >- + The canonical intent's SHA-256, what every record names. + value: ${{ steps.gate.outputs.intent-hash }} + reason-codes: + description: >- + The authority's reason codes, comma separated. + value: ${{ steps.gate.outputs.reason-codes }} policy-version: - description: "Policy version (string) that produced the verdict." + description: >- + The policy version the decision was made under. + value: ${{ steps.gate.outputs.policy-version }} policy-sha256: description: >- - sha256 of the injected `DECIONIS_POLICY.md` content (empty if no policy - file was found). The content-addressed version handle for the repo policy. + The SHA-256 of the repository policy file the intent carried. + value: ${{ steps.gate.outputs.policy-sha256 }} policy-path: - description: "Repo-relative path of the injected policy file, if any." - policy-enforced: description: >- - "true" when `policy-enforce` was set and a policy file was found (the - file's rules block is published as the active enforced bundle). - reason-code: - description: "Stable reason code (string) for the verdict, if returned." - badge-markdown: + Its path. + value: ${{ steps.gate.outputs.policy-path }} + mode: description: >- - Ready-to-paste Markdown for a "Governed by Decionis" badge linking to the - signed verify URL β€” drop it in a README or release notes. When a policy - file is present the link is pinned to its sha256 (`&policy=sha256:…`), so - the badge cryptographically binds the decision to the repo policy state. + `SHADOW` or `ENFORCEMENT`. + value: ${{ steps.gate.outputs.mode }} + fail-closed: + description: >- + `true` when no authoritative decision was reached and the gate refused. + value: ${{ steps.gate.outputs.fail-closed }} executed: description: >- - `true` if a `run` command was authorized and executed, `false` if it was - blocked. Empty when no `run` command was supplied. - execution-grant: + `true` when the command ran, `false` when it did not; empty without a command. + value: ${{ steps.gate.outputs.executed }} + claimed: + description: >- + `true` when a grant was claimed for the run. + value: ${{ steps.gate.outputs.claimed }} + exit-code: + description: >- + The command's exit code. + value: ${{ steps.gate.outputs.exit-code }} + outcome: + description: >- + `COMMITTED`, `FAILED` or `INDETERMINATE`, as finalized with the authority. + value: ${{ steps.gate.outputs.outcome }} + finalization: + description: >- + `RECORDED` when the authority recorded the outcome, `PENDING` when it did not. + value: ${{ steps.gate.outputs.finalization }} + badge-markdown: description: >- - The short-lived, single-use signed Execution Grant (EdDSA JWT), when - `request-grant: true` and the verdict authorizes. A target verifies it - against the public JWKS before acting. Empty otherwise. - grant-expires-at: - description: "ISO timestamp when the Execution Grant expires." + A β€œGoverned by Decionis” badge, linking to the proof. + value: ${{ steps.gate.outputs.badge-markdown }} runs: - using: "node20" - main: "src/index.mjs" + using: "composite" + steps: + - name: Govern the step + id: gate + uses: decionis/agent-safe-pipeline/govern@a29fb021cf3fd8dd031aa66f4f841bfa0ecb6c71 # master, 2026-09-21 (#245) + with: + api-key: ${{ inputs.api-key }} + tenant-id: ${{ inputs.tenant-id }} + mode: ${{ inputs.mode }} + action: ${{ inputs.action }} + resource: ${{ inputs.resource }} + payload: ${{ inputs.payload }} + environment: ${{ inputs.environment }} + run: ${{ inputs.run }} + shell: ${{ inputs.shell }} + fail-on: ${{ inputs.fail-on }} + escalation: ${{ inputs.escalation }} + approver: ${{ inputs.approver }} + approver-role: ${{ inputs.approver-role }} + policy-file: ${{ inputs.policy-file }} + comment: ${{ inputs.comment }} + show-attribution: ${{ inputs.show-attribution }} + api-url: ${{ inputs.api-url }} + timeout: ${{ inputs.timeout }} + intent-ttl: ${{ inputs.intent-ttl }} + report: ${{ inputs.report }} diff --git a/assets/pr-comment.svg b/assets/pr-comment.svg deleted file mode 100644 index 9bca8af..0000000 --- a/assets/pr-comment.svg +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - - - - πŸ›‘οΈ - github-actions bot commented Β· now - Β·Β·Β· - - - - πŸ›‘οΈ - DECIONIS Β· BLOCKED - - - πŸ›‘ Governed step β€” Blocked - - - - - - - - Verdict - block - - Policy - github_deploy_approval@v4 - - Reason - change_window_closed - - - πŸ”Ž Verify this decision β†’ - signed, tamper-evident proof. - - - - πŸ›‘οΈ Governed by Decionis Β· gate your own deploys with decionis/govern - - diff --git a/examples/DECIONIS_POLICY.devops.md b/examples/DECIONIS_POLICY.devops.md index 3948535..5be812d 100644 --- a/examples/DECIONIS_POLICY.devops.md +++ b/examples/DECIONIS_POLICY.devops.md @@ -1,131 +1,47 @@ -# Decionis Policy β€” DevOps & CI (example) +# Decionis Policy β€” DevOps and CI ## Scope -Applies to infrastructure changes (`terraform apply` / `destroy`, Pulumi, CDK), -production and on-chain / release deploys, and any AI-authored change that -touches those paths. +Deploys, releases, infrastructure changes and data migrations run from +GitHub Actions, GitLab CI or Jenkins in this repository. ## Rules +### Releases and production deploys + +- **Escalate** every production deploy and every tagged release to the release + manager; the pipeline holds while Decionis orchestrates the approval. +- **Block** deploys during a declared change freeze. +- **Allow** deploys to non-production environments from the default branch. + ### Infrastructure -- **Escalate** infrastructure **destroys** (`terraform destroy`, force-replace, - resource deletion) β€” these must never run unattended; require an authorized - operator. -- **Restrain** (require human review) production infrastructure **applies**. -- **Escalate** any change that touches **IAM, networking, or secrets**. +- **Escalate** an infrastructure change that destroys resources or touches IAM, + networking or secrets. +- **Allow** changes that only add or update resources within the plan's stated + blast radius. -### Deploys & releases +### Data -- **Restrain** on-chain / release deploys β€” review the release authority and - build inputs before anything ships. -- **Block** production deploys during a **change freeze**. +- **Escalate** a migration that drops a column or a table, or rewrites more + than a stated share of a table. +- **Allow** additive, reversible migrations. -### AI-authored changes +### Agent-authored changes -- **Restrain** any change authored by an **AI agent** that touches infra, - deploy, or release paths. -- **Allow** AI-authored docs / test-only changes. +- **Escalate** any change an AI agent authored on deploy, infrastructure or + migration paths; **allow** documentation and test-only changes. ## Thresholds -- Treat any change with an estimated **blast radius over $10,000** as high-risk +- Treat a change with an estimated blast radius over **$10,000** as high-risk and escalate. -- Require review for anything that modifies **secrets** or their rotation. - -## Enforced rules - -The prose above is documentation. The block below is what **enforces** when the -action runs with `policy-enforce: true` β€” and what the action's local policy -engine evaluates in-process on every run. JSON only; each rule has exactly one -of `all` / `any`, and an `action` of allow / block / restrain / escalate. -Fields are matched against the decision `payload` β€” `decision_type`, -`workflow_key`, and your own `context.*` keys. Give every rule an explicit -`priority` (higher evaluates first) and keep `"domain": "*"` so the rule -applies to every decision this repo gates. - -```decionis -{ - "version": 1, - "rules": [ - { - "name": "Escalate infrastructure destroy", - "priority": 100, - "domain": "*", - "any": [ - { "field": "decision_type", "op": "eq", "value": "infra-destroy" }, - { "field": "workflow_key", "op": "eq", "value": "infra-destroy" }, - { "field": "context.destroys_resources", "op": "eq", "value": true } - ], - "action": "escalate" - }, - { - "name": "Escalate infra changes touching IAM / networking / secrets", - "priority": 90, - "domain": "*", - "any": [ - { "field": "context.touches_iam", "op": "eq", "value": true }, - { "field": "context.touches_network", "op": "eq", "value": true }, - { "field": "context.touches_secrets", "op": "eq", "value": true } - ], - "action": "escalate" - }, - { - "name": "Block deploys during a change freeze", - "priority": 80, - "domain": "*", - "all": [{ "field": "context.change_freeze", "op": "eq", "value": true }], - "action": "block" - }, - { - "name": "Restrain production infrastructure apply", - "priority": 70, - "domain": "*", - "any": [ - { "field": "decision_type", "op": "eq", "value": "infra-apply" }, - { "field": "workflow_key", "op": "eq", "value": "infra-apply" } - ], - "action": "restrain" - }, - { - "name": "Restrain on-chain / release deploys", - "priority": 60, - "domain": "*", - "any": [ - { "field": "decision_type", "op": "eq", "value": "release-deploy" }, - { "field": "workflow_key", "op": "eq", "value": "release" }, - { "field": "workflow_key", "op": "eq", "value": "escrow-deploy" } - ], - "action": "restrain" - }, - { - "name": "Restrain AI-authored infra / deploy changes", - "priority": 50, - "domain": "*", - "all": [{ "field": "context.agent_generated", "op": "eq", "value": true }], - "action": "restrain" - } - ] -} -``` - - +- Require review for anything that modifies secrets or their rotation. diff --git a/examples/DECIONIS_POLICY.md b/examples/DECIONIS_POLICY.md index 7d05cf6..46f95e8 100644 --- a/examples/DECIONIS_POLICY.md +++ b/examples/DECIONIS_POLICY.md @@ -1,107 +1,58 @@ # Decionis Policy ## Scope Applies to deploys, infrastructure changes, database migrations, and -AI-generated pull requests in this repository. +agent-authored pull requests in this repository. ## Rules ### Production deploys - **Block** production deploys during a change freeze. -- **Escalate** any production deploy outside business hours (require a human - approver) before it runs. +- **Escalate** any production deploy outside business hours: a named approver + decides before it runs. - **Allow** production deploys that have a green CI run and an approved PR. ### Infrastructure (`terraform apply`, Pulumi, CDK) - **Escalate** changes that touch IAM, security groups, or networking. -- **Block** destructive actions (resource deletion, force-replace) without an - explicit approval label on the PR. +- **Block** destructive actions (resource deletion, forced replacement) without + an explicit approval label on the PR. ### Database migrations - **Escalate** any migration that drops a column or table. - **Allow** additive, reversible migrations. -### AI-generated changes +### Agent-authored changes -- **Restrain** (require human review) any change authored by an AI agent that - touches deploy, infra, or migration paths. -- **Allow** AI-authored docs/test-only changes. +- **Escalate** any change authored by an AI agent that touches deploy, + infrastructure, or migration paths. +- **Allow** agent-authored documentation and test-only changes. ## Thresholds -- Treat any action with an estimated blast radius over **$10,000** as high-risk - and escalate. -- Require two approvers for anything that modifies secrets or rotation. +- Treat any action with an estimated blast radius over **$10,000** as + high-risk and escalate. +- Require two approvers for anything that modifies secrets or their rotation. - -## Enforced rules - -The prose above is documentation. The block below is what actually **enforces** -when the action runs with `policy-enforce: true` β€” it compiles 1:1 into an -active policy bundle (idempotent by content hash; change the block, it -republishes) β€” and what the action's **local policy engine** evaluates -in-process on every run. JSON only; each rule has exactly one of `all` / `any`, -and an `action` of allow / block / restrain / escalate. Give every rule an -explicit `priority` (higher evaluates first) and keep `"domain": "*"` so the -rule applies to every decision this repo gates. - -```decionis -{ - "version": 1, - "rules": [ - { - "name": "Block production deploys during a change freeze", - "priority": 100, - "domain": "*", - "all": [ - { "field": "decision_type", "op": "eq", "value": "production-deploy" }, - { "field": "context.change_freeze", "op": "eq", "value": true } - ], - "action": "block" - }, - { - "name": "Escalate IAM / security-group changes", - "priority": 90, - "domain": "*", - "any": [ - { "field": "context.touches_iam", "op": "eq", "value": true }, - { "field": "context.touches_network", "op": "eq", "value": true } - ], - "action": "escalate" - }, - { - "name": "Restrain AI-authored deploy/infra/migration changes", - "priority": 80, - "domain": "*", - "all": [ - { "field": "context.agent_generated", "op": "eq", "value": true } - ], - "action": "restrain" - } - ] -} -``` diff --git a/examples/auto-merge-dependabot.yml b/examples/auto-merge-dependabot.yml index 771e0d3..f8ebaaa 100644 --- a/examples/auto-merge-dependabot.yml +++ b/examples/auto-merge-dependabot.yml @@ -1,46 +1,40 @@ -# Only auto-merge a Dependabot PR when Decionis returns `allow`. The verdict can -# weigh the update type (patch vs major), advisories, and your own policy β€” so -# low-risk bumps merge themselves while risky ones wait for a human. - -name: Dependabot auto-merge (governed) +# A dependency update merges itself only when Decionis allows it: the update's +# kind and size are the intent's parameters, and the merge is the verdict's +# consequence rather than a bot's default. +name: Dependabot auto-merge on: pull_request: - types: [opened, synchronize, reopened] permissions: contents: write pull-requests: write jobs: - govern-and-merge: - runs-on: ubuntu-latest + govern: if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest steps: - - name: Fetch update metadata - id: meta + - name: Read the update + id: metadata uses: dependabot/fetch-metadata@v2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Decionis verdict + - name: Decide id: gate - uses: decionis/govern@v1 + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: dependency_auto_merge - comment-pr: "true" - fail-on: never # don't fail the PR; gate the merge step instead + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} + action: dependency-merge + resource: pr:${{ github.event.pull_request.number }} + comment: "true" payload: | { - "dependency": "${{ steps.meta.outputs.dependency-names }}", - "update_type": "${{ steps.meta.outputs.update-type }}", - "ecosystem": "${{ steps.meta.outputs.package-ecosystem }}" + "dependency": "${{ steps.metadata.outputs.dependency-names }}", + "update_type": "${{ steps.metadata.outputs.update-type }}", + "ecosystem": "${{ steps.metadata.outputs.package-ecosystem }}" } - - - name: Enable auto-merge (only if allowed) - if: steps.gate.outputs.decision == 'allow' - run: gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}" + # The merge is the gated command: it does not run on a BLOCK. + run: gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/examples/gate-ai-agent-pr.yml b/examples/gate-ai-agent-pr.yml index c616d08..0be4f23 100644 --- a/examples/gate-ai-agent-pr.yml +++ b/examples/gate-ai-agent-pr.yml @@ -1,66 +1,54 @@ -# Govern AI-generated changes before they merge. -# -# When an AI coding agent (Claude Code, Copilot, Cursor, Codex, OpenHands, …) -# opens or updates a PR, evaluate it against policy + risk BEFORE it can be -# merged or trigger a deploy. The verdict is posted to the PR; on `block`/ -# `escalate` the merge gate stays closed until a human signs off. -# -# Detection here is heuristic β€” adjust to how your agents are wired (a bot -# account, a label, a branch prefix, or a commit trailer). - -name: AI change gate +# A pull request an AI agent opened is decided before it merges. The gate is +# advisory here (no wrapped command), so the verdict is an output the merge +# step reads; wrapping the merge command instead makes it enforced. +name: Govern agent-authored pull requests on: pull_request: - types: [opened, synchronize, reopened, labeled] + types: [opened, synchronize, reopened] permissions: contents: read pull-requests: write jobs: - govern-ai-change: + govern: runs-on: ubuntu-latest steps: - - name: Detect AI authorship + - uses: actions/checkout@v5 + + - name: Detect an agent author id: detect env: - ACTOR: ${{ github.actor }} - HEAD_REF: ${{ github.head_ref }} - LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + AUTHOR: ${{ github.event.pull_request.user.login }} + BODY: ${{ github.event.pull_request.body }} run: | - AI=false - case "$ACTOR" in - *copilot*|*cursor*|*claude*|*codex*|*openhands*|*[bot]) AI=true ;; - esac - case "$HEAD_REF" in ai/*|agent/*|copilot/*|cursor/*) AI=true ;; esac - case ",$LABELS," in *,ai-generated,*) AI=true ;; esac - echo "ai_generated=$AI" >> "$GITHUB_OUTPUT" + agent=false + case "$AUTHOR" in *"[bot]"*|*claude*|*copilot*|*cursor*|*codex*|*openhands*) agent=true ;; esac + case "$BODY" in *"Generated with"*|*"Co-Authored-By: Claude"*|*"Co-authored-by: Copilot"*) agent=true ;; esac + echo "ai_generated=$agent" >> "$GITHUB_OUTPUT" - - name: Decionis Action Gate + - name: Decide id: gate - uses: decionis/govern@v1 + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: ai_change_gate - action: ai-generated-pr - comment-pr: "true" - # Don't fail the check itself β€” gate the merge step below on the verdict. + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} + action: agent-pull-request + resource: pr:${{ github.event.pull_request.number }} fail-on: never + comment: "true" payload: | { - "author": "${{ github.actor }}", + "author": "${{ github.event.pull_request.user.login }}", "agent_generated": ${{ steps.detect.outputs.ai_generated }}, "changed_files": ${{ github.event.pull_request.changed_files }}, "additions": ${{ github.event.pull_request.additions }}, "deletions": ${{ github.event.pull_request.deletions }} } - # Only allow auto-merge when Decionis explicitly allowed the change. - # Anything block/escalate waits for a human β€” the PR comment shows why. - - name: Mark ready (allow only) - if: steps.gate.outputs.decision == 'allow' && steps.detect.outputs.ai_generated == 'true' - run: gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}" + - name: Merge when allowed + if: steps.gate.outputs.decision == 'ALLOW' && steps.detect.outputs.ai_generated == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh pr merge --auto --squash "${{ github.event.pull_request.html_url }}" diff --git a/examples/gate-deploy-azure.yml b/examples/gate-deploy-azure.yml deleted file mode 100644 index cce02f7..0000000 --- a/examples/gate-deploy-azure.yml +++ /dev/null @@ -1,66 +0,0 @@ -# Azure federated credentials β€” the cloud issues credentials only for a -# Decionis-authorized deploy. The deploy presents a Decionis-issued OIDC token; -# Azure's federated credential matches it on issuer + subject + audience, and -# Decionis issues the token only on decision=allow. -# -# One-time Azure setup (federated credential on a user-assigned managed identity -# or app registration) β€” see -# docs/architecture/execution-authority-tier3b-oidc-federation.md Β§4: -# az identity federated-credential create \ -# --name decionis-prod --identity-name deploy-mi --resource-group rg \ -# --issuer "https://api.decionis.com/v1/oidc/execution-grants" \ -# --subject "octo/repo@" \ -# --audiences "api://AzureADTokenExchange" - -name: Deploy to Azure (governed) - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - govern-and-deploy: - runs-on: ubuntu-latest - permissions: - contents: read - env: - API: https://api.decionis.com - AZURE_AUDIENCE: api://AzureADTokenExchange - steps: - - uses: actions/checkout@v4 - - - name: Decionis verdict + grant - id: gate - uses: decionis/govern@v1 - with: - api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval - action: production-deploy - request-grant: true - grant-audience: prod-azure - - - name: Exchange grant for an Azure OIDC token, then login + deploy - if: steps.gate.outputs.decision == 'allow' - env: - DECIONIS_API_KEY: ${{ secrets.DECIONIS_API_KEY }} - GRANT: ${{ steps.gate.outputs.execution-grant }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - run: | - set -euo pipefail - # Request the Decionis OIDC token (single-use, issued only on an authorized run). - ID_TOKEN=$(curl -fsS -X POST "$API/v1/protocol/execution-grants/exchange-oidc" \ - -H "Authorization: Bearer $DECIONIS_API_KEY" \ - -H 'content-type: application/json' \ - -d "{\"execution_grant\":\"$GRANT\",\"cloud_audience\":\"$AZURE_AUDIENCE\"}" \ - | jq -er '.id_token') - - # Exchange it for Azure credentials via federated identity. - az login --service-principal \ - --username "$AZURE_CLIENT_ID" \ - --tenant "$AZURE_TENANT_ID" \ - --federated-token "$ID_TOKEN" - - ./scripts/deploy.sh diff --git a/examples/gate-deploy-broker.yml b/examples/gate-deploy-broker.yml deleted file mode 100644 index 04a5dae..0000000 --- a/examples/gate-deploy-broker.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Brokered credentials β€” keep deploy secrets out of CI entirely. Decionis holds -# the registry token; the deploy step receives it only in exchange for an -# authorized run, so an unauthorized deploy has nothing to deploy with. -# -# One-time setup (org admin), registering the target's secret with Decionis: -# curl -X POST "$API/v1/protocol/targets/credentials" \ -# -H "Authorization: Bearer $DECIONIS_API_KEY" -H 'content-type: application/json' \ -# -d '{"org_id":"","audience":"prod-us-east","kind":"token","secret":""}' - -name: Deploy (brokered credentials) - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - govern-and-deploy: - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v4 - - - name: Decionis verdict + grant - id: gate - uses: decionis/govern@v1 - with: - api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval - action: production-deploy - request-grant: true - grant-audience: prod-us-east - - - name: Redeem grant for credentials, then deploy - if: steps.gate.outputs.decision == 'allow' - env: - DECIONIS_API_KEY: ${{ secrets.DECIONIS_API_KEY }} - GRANT: ${{ steps.gate.outputs.execution-grant }} - API: https://api.decionis.com - run: | - set -euo pipefail - # Exchange the authorized run for the registry token. - - CRED=$(curl -fsS -X POST "$API/v1/protocol/execution-grants/redeem" \ - -H "Authorization: Bearer $DECIONIS_API_KEY" \ - -H 'content-type: application/json' \ - -d "{\"execution_grant\":\"$GRANT\",\"audience\":\"prod-us-east\"}" \ - | jq -er '.credential') - ./scripts/deploy.sh --registry-token "$CRED" diff --git a/examples/gate-deploy-gcp-wif.yml b/examples/gate-deploy-gcp-wif.yml deleted file mode 100644 index d3162c0..0000000 --- a/examples/gate-deploy-gcp-wif.yml +++ /dev/null @@ -1,68 +0,0 @@ -# GCP Workload Identity Federation β€” the cloud issues credentials only for a -# Decionis-authorized deploy. The deploy presents a Decionis-issued OIDC token -# carrying decision=allow, and GCP's own STS requires it via the provider's -# attribute-condition (assertion.decision == 'allow'). -# -# One-time GCP setup: see -# docs/architecture/execution-authority-tier3b-oidc-federation.md Β§3. - -name: Deploy to GCP (governed) - -on: - push: - branches: [main] - workflow_dispatch: - -jobs: - govern-and-deploy: - runs-on: ubuntu-latest - permissions: - contents: read - env: - API: https://api.decionis.com - AUD: //iam.googleapis.com/projects/PROJECT_NUM/locations/global/workloadIdentityPools/decionis/providers/gh - steps: - - uses: actions/checkout@v4 - - - name: Decionis verdict + grant - id: gate - uses: decionis/govern@v1 - with: - api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval - action: production-deploy - request-grant: true - grant-audience: prod-gcp - - - name: Exchange grant for a GCP OIDC token - if: steps.gate.outputs.decision == 'allow' - id: oidc - env: - DECIONIS_API_KEY: ${{ secrets.DECIONIS_API_KEY }} - GRANT: ${{ steps.gate.outputs.execution-grant }} - run: | - set -euo pipefail - ID_TOKEN=$(curl -fsS -X POST "$API/v1/protocol/execution-grants/exchange-oidc" \ - -H "Authorization: Bearer $DECIONIS_API_KEY" \ - -H 'content-type: application/json' \ - -d "{\"execution_grant\":\"$GRANT\",\"cloud_audience\":\"$AUD\"}" \ - | jq -er '.id_token') - echo "$ID_TOKEN" > "$RUNNER_TEMP/oidc_token" - echo "token-file=$RUNNER_TEMP/oidc_token" >> "$GITHUB_OUTPUT" - - # The federated token now exchanges for GCP creds (auth action reads the - # token file produced above). GCP rejects it unless decision == 'allow'. - - name: Authenticate to GCP via WIF - if: steps.gate.outputs.decision == 'allow' - uses: google-github-actions/auth@v2 - with: - workload_identity_provider: projects/PROJECT_NUM/locations/global/workloadIdentityPools/decionis/providers/gh - service_account: deploy@PROJECT.iam.gserviceaccount.com - token_format: access_token - # Use the Decionis-issued OIDC token instead of GitHub's: - id_token_file: ${{ steps.oidc.outputs.token-file }} - - - name: Deploy - if: steps.gate.outputs.decision == 'allow' - run: ./scripts/deploy.sh diff --git a/examples/gate-deploy.yml b/examples/gate-deploy.yml index 2784c8d..763e03e 100644 --- a/examples/gate-deploy.yml +++ b/examples/gate-deploy.yml @@ -1,38 +1,42 @@ -# Enforce a production deploy with Decionis β€” the deploy runs THROUGH the gate. -# -# Because the deploy command is passed to `run:`, it cannot execute without an -# authorizing verdict. There is no `if:` to delete: on block/escalate the -# command never runs and the job fails. Drop into .github/workflows/. - -name: Deploy (governed) +# A production deploy that cannot run without Decionis authorizing it: the +# deploy script runs through the gate, so an ALLOW is a claimed grant and a +# BLOCK is a step that fails with the command never started. What happened is +# finalized with Decionis afterwards and joins the signed Decision Dossier. +name: Deploy to production on: push: branches: [main] - workflow_dispatch: + +permissions: + contents: read jobs: - govern-and-deploy: + deploy: runs-on: ubuntu-latest - permissions: - contents: read + environment: production steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Govern + deploy - uses: decionis/govern@v1 + - name: Govern the deploy + id: gate + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_deploy_approval + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} action: production-deploy - mode: enforce + resource: service:api + environment: production payload: | { - "environment": "production", "service": "api", - "blast_radius": "high" + "ref": "${{ github.ref }}", + "sha": "${{ github.sha }}", + "actor": "${{ github.actor }}" } - # Decionis runs this ONLY if it authorizes the deploy. On block or - # escalate it never runs and the step fails β€” no skippable `if:`. - run: ./scripts/deploy.sh + run: ./scripts/deploy.sh # runs only on an ALLOW whose grant this step claimed + + - name: Record the proof + if: always() + run: | + echo "decision=${{ steps.gate.outputs.decision }} dossier=${{ steps.gate.outputs.dossier-id }} outcome=${{ steps.gate.outputs.outcome }}" diff --git a/examples/gate-pr-comment.yml b/examples/gate-pr-comment.yml index 555e13d..3bb1113 100644 --- a/examples/gate-pr-comment.yml +++ b/examples/gate-pr-comment.yml @@ -1,30 +1,34 @@ -# Run Decionis in shadow mode and post the verdict as a PR comment so the -# reviewer sees what the governed verdict would have been β€” without ever -# failing the build. -# -# Pattern: install shadow first, observe verdicts on PRs for a sprint, then -# flip `mode` to `enforce`. - -name: Decionis (shadow on PRs) +# Shadow: nothing is enforced and no build fails for the gate's sake. Every +# pull request gets the verdict Decionis would have given, as a comment that +# updates in place, so a team sees what governing would do before it does. +name: Decionis shadow on: pull_request: - types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write jobs: - shadow-verdict: + shadow: runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Decionis shadow verdict - uses: decionis/govern@v1 + - name: What would Decionis say? + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: github_pr_change_intent + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} mode: shadow - comment-pr: "true" + action: pull-request-change + resource: pr:${{ github.event.pull_request.number }} + payload: | + { + "title": ${{ toJSON(github.event.pull_request.title) }}, + "author": "${{ github.event.pull_request.user.login }}", + "changed_files": ${{ github.event.pull_request.changed_files }}, + "base": "${{ github.base_ref }}" + } + comment: "true" diff --git a/examples/gate-release.yml b/examples/gate-release.yml index 4597700..e718408 100644 --- a/examples/gate-release.yml +++ b/examples/gate-release.yml @@ -1,42 +1,35 @@ -# Require a Decionis verdict before a tagged release ships. The signed verify -# URL is attached to the GitHub Release notes as portable proof that the -# release passed governance. - -name: Release (governed) +# A tagged release ships only on an ALLOW. The publish command runs through +# the gate; a BLOCK never publishes; an ESCALATE waits for the release +# manager's approval, orchestrated by Decionis, within the intent's lifetime. +name: Release on: push: tags: ["v*"] +permissions: + contents: write + id-token: write + jobs: - govern-and-release: + release: runs-on: ubuntu-latest - permissions: - contents: write # to create the release steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + registry-url: https://registry.npmjs.org - - name: Decionis verdict - id: gate - uses: decionis/govern@v1 + - name: Govern the release + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: release_gate - fail-on: block + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} + action: release-publish + resource: npm:${{ github.event.repository.name }} payload: | - { - "release_tag": "${{ github.ref_name }}", - "repository": "${{ github.repository }}" - } - - - name: Create release (only if allowed) - if: steps.gate.outputs.decision == 'allow' - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release create "${{ github.ref_name }}" \ - --title "${{ github.ref_name }}" \ - --notes "Governed release. ${{ steps.gate.outputs.badge-markdown }} - - Verify: ${{ steps.gate.outputs.verify-url }}" + { "tag": "${{ github.ref_name }}", "sha": "${{ github.sha }}", "actor": "${{ github.actor }}" } + escalation: managed + approver-role: RELEASE_MANAGER + run: npm publish --provenance --access public diff --git a/examples/gate-terraform.yml b/examples/gate-terraform.yml index 43649fe..7965778 100644 --- a/examples/gate-terraform.yml +++ b/examples/gate-terraform.yml @@ -1,55 +1,50 @@ -# Gate `terraform apply` on a Decionis verdict driven by the plan's blast radius. -# The plan's resource-change counts feed the policy, so a large/destructive plan -# can require escalation while a no-op or additive plan sails through. - -name: Terraform (governed apply) +# terraform apply runs through the gate with the plan's own numbers as the +# intent's parameters, so policy decides on the blast radius Terraform +# reported and the dossier records it. +name: Terraform apply on: push: branches: [main] - paths: ["infra/**", "**.tf"] - workflow_dispatch: + paths: ["infra/**"] + +permissions: + contents: read jobs: - plan-and-govern: + apply: runs-on: ubuntu-latest - permissions: - contents: read + defaults: + run: + working-directory: infra steps: - - uses: actions/checkout@v4 - + - uses: actions/checkout@v5 - uses: hashicorp/setup-terraform@v3 - - name: Terraform plan + - name: Plan id: plan - working-directory: infra run: | terraform init -input=false terraform plan -input=false -out=tfplan - # Summarize the plan into change counts the policy can evaluate. terraform show -json tfplan > plan.json - ADD=$(jq '[.resource_changes[]?|select(.change.actions|index("create"))]|length' plan.json) - DESTROY=$(jq '[.resource_changes[]?|select(.change.actions|index("delete"))]|length' plan.json) - echo "add=$ADD" >> "$GITHUB_OUTPUT" - echo "destroy=$DESTROY" >> "$GITHUB_OUTPUT" + jq -c '{ + add: [.resource_changes[]? | select(.change.actions | index("create"))] | length, + change: [.resource_changes[]? | select(.change.actions | index("update"))] | length, + destroy: [.resource_changes[]? | select(.change.actions | index("delete"))] | length, + touches_iam: ([.resource_changes[]? | select(.type | test("iam|security_group"))] | length) > 0 + }' plan.json > summary.json + echo "summary=$(cat summary.json)" >> "$GITHUB_OUTPUT" - - name: Decionis verdict - id: gate - uses: decionis/govern@v1 + - name: Govern the apply + uses: decionis/govern@v2 with: api-key: ${{ secrets.DECIONIS_API_KEY }} - org-id: ${{ secrets.DECIONIS_ORG_ID }} - workflow-key: terraform_apply_gate - fail-on: block_or_escalate - payload: | - { - "environment": "production", - "tool": "terraform", - "resources_created": ${{ steps.plan.outputs.add }}, - "resources_destroyed": ${{ steps.plan.outputs.destroy }} - } - - - name: Terraform apply - if: steps.gate.outputs.decision == 'allow' - working-directory: infra - run: terraform apply -input=false tfplan + tenant-id: ${{ vars.DECIONIS_TENANT_ID }} + action: terraform-apply + resource: infra:tfplan + environment: production + payload: ${{ steps.plan.outputs.summary }} + # An ESCALATE holds the step while Decionis orchestrates the approval. + escalation: managed + approver-role: PLATFORM_LEAD + run: cd infra && terraform apply -input=false tfplan diff --git a/install.sh b/install.sh deleted file mode 100755 index 0b6e7b4..0000000 --- a/install.sh +++ /dev/null @@ -1,469 +0,0 @@ -#!/usr/bin/env sh -# Decionis Govern installer β€” canonical source for https://decionis.com/govern/install.sh -# -# curl -fsSL https://decionis.com/govern/install.sh | sh -# curl -fsSL https://decionis.com/govern/install.sh | sh -s -- --pr -# curl -fsSL https://decionis.com/govern/install.sh | sh -s -- --pr --inject -# -# Writes a shadow-mode governance workflow and a starter DECIONIS_POLICY.md, -# optionally injects an observe-only gate step into existing workflows -# (--inject), and optionally opens the onboarding pull request (--pr). -# POSIX sh β€” runs under dash, macOS bash 3.2, and busybox sh. No secrets are -# read, stored, or transmitted; the api-key is always a GitHub secrets ref. -set -eu - -INSTALLER_VERSION="1.9.0" -BRANCH_NAME="feature/add-decionis-governance" -MARKER="# decionis-govern (auto)" -WORKFLOW_FILE=".github/workflows/decionis-govern.yml" -POLICY_FILE="DECIONIS_POLICY.md" - -MODE="shadow" -WORKFLOW_KEY="github_pr_change_intent" -ORG_ID_REF='${{ secrets.DECIONIS_ORG_ID }}' -DO_INJECT=0 -DO_PR=0 -DRY_RUN=0 -FORCE=0 -CREATED="" -MODIFIED="" - -usage() { - cat <<'EOF' -Decionis Govern installer - -Usage: install.sh [options] - (no flags) Write .github/workflows/decionis-govern.yml and a - starter DECIONIS_POLICY.md (each skipped if present). - --inject Also insert an observe-only Decionis shadow step as the - first step of every job in existing workflow files. - Insert-only and conservative: files/jobs it cannot edit - safely are skipped with a reason. - --pr Create branch feature/add-decionis-governance, commit - the generated files, push, and open the PR (gh CLI if - available, otherwise prints the compare URL). - --dry-run Show every file and diff that would be written; write - nothing and run no git commands. - --force Proceed despite a dirty worktree or detached HEAD; with - --pr, use a timestamp-suffixed branch if the branch - already exists. - --mode shadow (default) or enforce β€” mode for the generated - workflow. Injected steps are always shadow. - --org-id Inline a literal Decionis org id instead of - ${{ secrets.DECIONIS_ORG_ID }} (org ids are not secret). - --workflow-key Workflow key for generated and injected steps - (default: github_pr_change_intent). - -h, --help This help. - -Exit codes: 0 ok/nothing-to-do Β· 2 not a git work tree Β· 3 detached HEAD -(--pr) Β· 4 dirty worktree (--pr) Β· 5 --pr without an origin remote Β· 64 bad flag -EOF -} - -fail() { - code=$1 - shift - echo "install.sh: $*" >&2 - exit "$code" -} - -while [ $# -gt 0 ]; do - case "$1" in - --inject) DO_INJECT=1 ;; - --pr) DO_PR=1 ;; - --dry-run) DRY_RUN=1 ;; - --force) FORCE=1 ;; - --mode) - shift - [ $# -gt 0 ] || fail 64 "--mode needs a value (shadow|enforce)" - case "$1" in - shadow | enforce) MODE=$1 ;; - *) fail 64 "--mode must be 'shadow' or 'enforce' (got '$1')" ;; - esac - ;; - --org-id) - shift - [ $# -gt 0 ] || fail 64 "--org-id needs a value" - ORG_ID_REF=$1 - ;; - --workflow-key) - shift - [ $# -gt 0 ] || fail 64 "--workflow-key needs a value" - WORKFLOW_KEY=$1 - ;; - -h | --help) - usage - exit 0 - ;; - *) - usage >&2 - fail 64 "unknown flag: $1" - ;; - esac - shift -done - -git rev-parse --is-inside-work-tree >/dev/null 2>&1 || - fail 2 "not inside a git work tree β€” run this from your repository root" -cd "$(git rev-parse --show-toplevel)" - -# Git-state gates apply only to --pr (which branches, commits, and pushes); -# plain runs merely add new files and are safe in any worktree state. -if [ "$DO_PR" -eq 1 ] && [ "$DRY_RUN" -eq 0 ]; then - if ! git symbolic-ref -q HEAD >/dev/null 2>&1; then - [ "$FORCE" -eq 1 ] || fail 3 "detached HEAD β€” check out a branch or pass --force" - fi - if [ -n "$(git status --porcelain 2>/dev/null)" ]; then - [ "$FORCE" -eq 1 ] || fail 4 "working tree is dirty β€” commit/stash first or pass --force" - fi -fi - -# ── Artifact generation ────────────────────────────────────────────────────── - -# write_artifact β€” content on stdin; skips existing files, honors --dry-run. -write_artifact() { - wa_path=$1 - if [ -e "$wa_path" ]; then - cat >/dev/null - echo "skip: $wa_path already exists" - return 0 - fi - if [ "$DRY_RUN" -eq 1 ]; then - echo "would create: $wa_path" - sed 's/^/ | /' - return 0 - fi - mkdir -p "$(dirname "$wa_path")" - cat >"$wa_path" - CREATED="$CREATED $wa_path" - echo "created: $wa_path" -} - -write_workflow() { - write_artifact "$WORKFLOW_FILE" <` - reads payload keys; `decision_type` derives from the action label. - - Verdicts: allow / block / restrain / escalate. ---> - -## Scope - -Applies to deploys, infrastructure changes, and AI-authored changes gated -through `decionis/govern` in this repository. Start in `mode: shadow`, review -the verdicts on your PRs, then switch to `enforce`. - -## Rules - -- **Block** deploys during a change freeze. -- **Escalate** anything that destroys infrastructure or touches secrets. -- **Restrain** (require human review) AI-authored changes to deploy paths. - -## Enforced rules - -```decionis -{ - "version": 1, - "rules": [ - { - "name": "Block deploys during a change freeze", - "priority": 100, - "domain": "*", - "all": [{ "field": "context.change_freeze", "op": "eq", "value": true }], - "action": "block" - }, - { - "name": "Escalate destructive or secret-touching changes", - "priority": 90, - "domain": "*", - "any": [ - { "field": "context.destroys_resources", "op": "eq", "value": true }, - { "field": "context.touches_secrets", "op": "eq", "value": true } - ], - "action": "escalate" - }, - { - "name": "Restrain AI-authored deploy changes", - "priority": 80, - "domain": "*", - "all": [{ "field": "context.agent_generated", "op": "eq", "value": true }], - "action": "restrain" - } - ] -} -``` -EOF -} - -# ── Workflow injection (--inject) ──────────────────────────────────────────── -# Single awk pass per file, insert-only: the shadow step is inserted as the -# FIRST step of each job (observe-only, no checkout dependency), re-indented to -# match the job's existing step items, with continue-on-error so it can never -# break the host job. Anything the parser is unsure about is skipped loudly. - -inject_one() { - in_file=$1 - if grep -q 'decionis/govern' "$in_file"; then - echo "skip: $in_file (already uses decionis/govern)" - return 0 - fi - if grep -q "$(printf '\t')" "$in_file"; then - echo "skip: $in_file (contains tabs β€” add the step manually)" - return 0 - fi - if ! grep -q '^jobs:' "$in_file"; then - echo "skip: $in_file (no top-level 'jobs:' key)" - return 0 - fi - - in_tmp="$in_file.decionis-tmp" - in_err="$in_file.decionis-err" - awk -v org_ref="$ORG_ID_REF" -v wk="$WORKFLOW_KEY" -v marker="$MARKER" ' - function indent_of(line, n) { - n = 0 - while (substr(line, n + 1, 1) == " ") n++ - return n - } - function pad(n, s, i) { - s = "" - for (i = 0; i < n; i++) s = s " " - return s - } - function emit_step(n, p) { - p = pad(n) - print p "- name: Decionis shadow verdict " marker - print p " uses: decionis/govern@v1" - print p " continue-on-error: true" - print p " with:" - print p " api-key: ${{ secrets.DECIONIS_API_KEY }}" - print p " org-id: " org_ref - print p " workflow-key: " wk - print p " mode: shadow" - } - BEGIN { in_jobs = 0; job_indent = -1; child_indent = -1; state = ""; steps_indent = -1 } - { - line = $0 - if (!in_jobs) { - print line - if (line ~ /^jobs:[ \t]*$/) in_jobs = 1 - next - } - if (line ~ /^[^ \t]/) { in_jobs = 0; print line; next } # jobs section over - ind = indent_of(line) - blank = (line ~ /^[ \t]*$/) - comment = (line ~ /^[ ]*#/) - - if (!blank && !comment) { - if (job_indent < 0 && line ~ /^[ ]+[^ ]/) job_indent = ind - if (ind == job_indent && line ~ /^[ ]+[A-Za-z0-9_."'"'"'-]+:[ \t]*$/) { - state = "in_job"; child_indent = -1; steps_indent = -1 - } else if (state != "" && child_indent < 0 && ind > job_indent) { - child_indent = ind - } - } - - if (state == "in_job" && !blank && !comment && ind == child_indent) { - if (line ~ /^[ ]+uses:/) { - print "note: reusable-workflow job skipped" > "/dev/stderr" - state = "skip_job" - } else if (line ~ /^[ ]+steps:[ \t]*$/) { - state = "await_item"; steps_indent = ind; print line; next - } else if (line ~ /^[ ]+steps:[ \t]*[^ \t]/) { - print "note: flow-style or anchored steps skipped" > "/dev/stderr" - state = "skip_job" - } - } - - if (state == "await_item") { - if (blank || comment) { print line; next } - if (ind > steps_indent && line ~ /^[ ]*- /) { - emit_step(ind) - state = "done_job" - print line - next - } - print "note: job with empty or unrecognized steps skipped" > "/dev/stderr" - state = "skip_job" - } - print line - } - ' "$in_file" >"$in_tmp" 2>"$in_err" || { - rm -f "$in_tmp" "$in_err" - echo "skip: $in_file (could not parse)" - return 0 - } - - while IFS= read -r note; do - [ -n "$note" ] && echo " $in_file: $note" - done <"$in_err" - rm -f "$in_err" - - added=$(grep -c "decionis-govern (auto)" "$in_tmp" || true) - if [ "$added" -eq 0 ] || cmp -s "$in_file" "$in_tmp"; then - rm -f "$in_tmp" - echo "skip: $in_file (no injectable jobs)" - return 0 - fi - if [ "$DRY_RUN" -eq 1 ]; then - echo "would inject $added step(s) into $in_file:" - diff -u "$in_file" "$in_tmp" || true - rm -f "$in_tmp" - return 0 - fi - mv "$in_tmp" "$in_file" - MODIFIED="$MODIFIED $in_file" - echo "injected $added step(s) into $in_file" -} - -inject_all() { - ia_found=0 - for wf in .github/workflows/*.yml .github/workflows/*.yaml; do - [ -e "$wf" ] || continue - case "$wf" in - "$WORKFLOW_FILE") continue ;; - esac - ia_found=1 - inject_one "$wf" - done - [ "$ia_found" -eq 1 ] || echo "inject: no existing workflow files found under .github/workflows/" -} - -# ── Pull request (--pr) ────────────────────────────────────────────────────── - -repo_web_url() { - rwu_origin=$(git remote get-url origin 2>/dev/null) || return 1 - case "$rwu_origin" in - git@*) - echo "$rwu_origin" | sed -e 's/^git@//' -e 's/:/\//' -e 's/\.git$//' -e 's/^/https:\/\//' - ;; - http://* | https://*) - echo "$rwu_origin" | sed -e 's/\.git$//' - ;; - *) return 1 ;; - esac -} - -open_pr() { - git remote get-url origin >/dev/null 2>&1 || - fail 5 "--pr needs an 'origin' remote to push to" - to_commit=$(echo "$CREATED $MODIFIED" | tr -s ' ' | sed 's/^ //;s/ $//') - if [ -z "$to_commit" ]; then - echo "--pr: nothing new to commit; skipping PR" - return 0 - fi - - base_branch=$(git rev-parse --abbrev-ref HEAD) - target_branch=$BRANCH_NAME - if git rev-parse --verify -q "$target_branch" >/dev/null 2>&1; then - [ "$FORCE" -eq 1 ] || fail 1 "branch $target_branch already exists β€” pass --force for a suffixed branch" - target_branch="$BRANCH_NAME-$(date +%s)" - fi - - pr_title="Add Decionis governance gate ($MODE mode)" - pr_body="## Governed by Decionis - -This PR adds a **$MODE-mode** [decionis/govern](https://github.com/decionis/govern) gate: - -- \`$WORKFLOW_FILE\` β€” records a signed verdict on every pull request$([ "$DO_INJECT" -eq 1 ] && printf '\n- observe-only shadow steps injected into existing workflows (`continue-on-error: true` β€” they can never break a build)' || true) -- \`$POLICY_FILE\` β€” the repo governance policy, evaluated locally in microseconds and recorded (by sha256) on every signed Decision Dossier - -### Before merging -1. Add the \`DECIONIS_API_KEY\` secret (Settings β†’ Secrets β†’ Actions).$([ "$ORG_ID_REF" = '${{ secrets.DECIONIS_ORG_ID }}' ] && printf '\n2. Add the `DECIONIS_ORG_ID` secret (your Decionis org id).' || true) - -Shadow mode **never fails a build** β€” merge, watch the verdicts on PRs, then flip \`mode: enforce\` when ready. - -[![Governed by Decionis](https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white)](https://github.com/decionis/govern) - ---- -Generated by \`install.sh\` v$INSTALLER_VERSION." - - echo "branch: $target_branch (from $base_branch)" - git checkout -q -b "$target_branch" - # shellcheck disable=SC2086 β€” word splitting over the file list is intended - git add -- $to_commit - git commit -q -m "chore: add Decionis governance gate ($MODE mode)" \ - -m "Generated by decionis/govern install.sh v$INSTALLER_VERSION. Shadow mode records signed verdicts without failing builds." - git push -u origin "$target_branch" - - if command -v gh >/dev/null 2>&1; then - if gh pr create --title "$pr_title" --body "$pr_body" --base "$base_branch" --head "$target_branch"; then - return 0 - fi - echo "gh pr create failed β€” open it manually:" >&2 - fi - web=$(repo_web_url) || { - echo "pushed: open a PR from $target_branch β†’ $base_branch on your host" - return 0 - } - echo "Open the PR: $web/compare/$base_branch...$target_branch?expand=1" -} - -# ── Main ───────────────────────────────────────────────────────────────────── - -echo "Decionis Govern installer v$INSTALLER_VERSION (mode: $MODE$([ "$DRY_RUN" -eq 1 ] && printf ', dry run'))" -write_workflow -write_policy -[ "$DO_INJECT" -eq 1 ] && inject_all - -if [ "$DRY_RUN" -eq 1 ]; then - echo "dry run: nothing was written" - exit 0 -fi - -if [ -z "$CREATED$MODIFIED" ]; then - echo "All Decionis artifacts already present; nothing to do." - exit 0 -fi - -if [ "$DO_PR" -eq 1 ]; then - open_pr -else - echo "" - echo "Next steps:" - echo " 1. Add the DECIONIS_API_KEY repo secret (and DECIONIS_ORG_ID unless --org-id was given)." - echo " 2. Commit the changes β€” or re-run with --pr to open the onboarding pull request:" - echo " curl -fsSL https://decionis.com/govern/install.sh | sh -s -- --pr" -fi diff --git a/llms.txt b/llms.txt index 37d6ca0..1192389 100644 --- a/llms.txt +++ b/llms.txt @@ -1,31 +1,20 @@ -# Decionis Action Gate (decionis/govern) +# Decionis Govern (decionis/govern) -> GitHub Action that governs high-risk workflow steps β€” deploys, migrations, releases, infra changes, AI-generated PRs β€” before they run. One step evaluates the action against your committed policy and returns a deterministic verdict (allow / block / restrain / escalate), sealed as a signed, publicly verifiable Decision Dossier. Shadow mode records verdicts without ever failing a build; enforce mode gates for real. +> GitHub Action, the Marketplace address of Govern: one Decionis verdict before a workflow step runs β€” a deploy, a migration, an infrastructure change, a release, an agent's action β€” with a signed Decision Dossier of it. The step becomes an execution intent, Decionis decides on exactly that intent, the command runs only on an ALLOW whose single-use grant the step claimed first, and the outcome is finalized into the dossier. Nothing is decided locally. Quickstart: -- Add a workflow step: `uses: decionis/govern@v1` -- One-command install into an existing repo: `curl -fsSL https://decionis.com/govern/install.sh | sh` -- Policy is committed in-repo as `DECIONIS_POLICY.md` (YAML also accepted); the structured rules block can be evaluated in-process in ~1 ms (`local-eval: auto`) while the API notarizes the signed dossier off the critical path. -- Requires a `DECIONIS_API_KEY` workflow secret (`protocol:evaluate` scope) and your Decionis org id. +- Add a workflow step: `uses: decionis/govern@v2` with `api-key` (a secret), `tenant-id` (a variable), `action`, and `run` (the command the verdict gates). +- `mode: shadow` starts the command at once and records the verdict beside it; it never fails a build. +- The gate is one Go binary for GitHub Actions, GitLab CI, Jenkins and any runner; its source, tests and releases are in decionis/agent-safe-pipeline under govern/. -Key inputs: `api-key`, `org-id`, `workflow-key`, `mode` (enforce | shadow), `fail-on`, `run` (command executed only if authorized), `request-grant` (short-lived signed Execution Grant), `payload`, `policy-file`, `policy-enforce`, `local-eval`, `comment-pr`. +Key inputs: `api-key`, `tenant-id`, `mode` (enforce | shadow), `action`, `resource`, `payload`, `environment`, `run`, `fail-on`, `escalation` (managed), `approver`, `approver-role`, `policy-file`, `comment`. -Key outputs: `decision`, `decision-source`, `dossier-id`, `verify-url`, `policy-sha256`, `badge-markdown`, `executed`, `execution-grant`. +Key outputs: `decision`, `decision-id`, `dossier-id`, `verify-url`, `intent-hash`, `reason-codes`, `policy-version`, `executed`, `claimed`, `outcome`, `finalization`, `badge-markdown`. ## Docs -- [Quickstart](https://decionis.com/docs/quickstart?utm_source=github&utm_medium=llms_txt&utm_campaign=dev_discovery): First verdict in minutes -- [API reference](https://decionis.com/docs/api?utm_source=github&utm_medium=llms_txt&utm_campaign=dev_discovery): Evaluate decisions and retrieve dossiers -- [Dossier verification](https://decionis.com/verify/decision-dossiers?utm_source=github&utm_medium=llms_txt&utm_campaign=dev_discovery): Verify signed Decision Dossiers, including offline -- [Marketplace listing](https://github.com/marketplace/actions/decionis-action-gate): Decionis Action Gate on the GitHub Marketplace - -## Try it - -- [Sandbox](https://decionis.com/sandbox?utm_source=github&utm_medium=llms_txt&utm_campaign=dev_discovery): Run a live policy check without an account - -## Related - -- [Decionis](https://decionis.com/?utm_source=github&utm_medium=llms_txt&utm_campaign=dev_discovery): The deterministic decision layer between intent and execution -- [Organization profile](https://github.com/decionis): All public Decionis repositories -- [Security policy](https://github.com/decionis/.github/blob/master/SECURITY.md): Vulnerability disclosure process and SLAs +- [The gate's README](https://github.com/decionis/agent-safe-pipeline/blob/master/govern/README.md): every setting, output, exit code and runner surface +- [Govern, the workflow gate](https://github.com/decionis/agent-safe-pipeline/blob/master/docs/govern.md): what one governed step is, holding a step for a person, what the record shows +- [Quickstart](https://decionis.com/quickstart?source=github_action): a workspace and a key in minutes +- [Decision Dossier verification](https://decionis.com/verify/decision-dossiers): verify a signed dossier, including offline diff --git a/package.json b/package.json deleted file mode 100644 index 823c697..0000000 --- a/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@decionis/github-action", - "version": "1.9.0", - "description": "Govern a GitHub Actions step on a Decionis Decision Dossier β€” gate deploys, releases, infra changes, and agent-triggered actions on a signed verdict.", - "private": true, - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/decionis/govern.git" - }, - "scripts": { - "test": "node --test test/*.test.mjs", - "lint": "eslint src test", - "format": "prettier --check . --ignore-path ../../.prettierignore", - "format:fix": "prettier --write . --ignore-path ../../.prettierignore" - }, - "engines": { - "node": ">=20" - }, - "files": [ - "action.yml", - "install.sh", - "src", - "assets", - "README.md", - "LICENSE", - "SECURITY.md", - "SUPPORT.md" - ], - "license": "Apache-2.0", - "homepage": "https://decionis.com/marketplace/github" -} diff --git a/src/api-client.mjs b/src/api-client.mjs deleted file mode 100644 index 96bec6b..0000000 --- a/src/api-client.mjs +++ /dev/null @@ -1,122 +0,0 @@ -// @ts-check -// Decionis API client β€” the only module that talks to the Decionis backend. -// Extracted from index.mjs so the blocking path and the background -// (speculative / notarization) pipeline share one implementation. - -/** - * POST /v1/protocol/evaluate-decision. - * - * Returns `{ ok: true, status, data }` on 2xx and - * `{ ok: false, status, bodyText }` on any other HTTP status. - * Throws on network errors, the internal timeout, or an external abort β€” - * the caller decides fail-open vs fail-closed per mode. - * - * @param {{ apiBaseUrl: string, apiKey: string, body: object, - * timeoutMs: number, signal?: AbortSignal }} options - */ -export async function evaluateDecision({ apiBaseUrl, apiKey, body, timeoutMs, signal }) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - const onExternalAbort = () => controller.abort(); - if (signal) { - if (signal.aborted) { - clearTimeout(timer); - throw new Error("Decionis evaluate-decision aborted before the request started"); - } - signal.addEventListener("abort", onExternalAbort, { once: true }); - } - try { - const res = await fetch(`${apiBaseUrl}/v1/protocol/evaluate-decision`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "content-type": "application/json", - accept: "application/json", - "x-decionis-source": "github_actions", - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - if (!res.ok) { - const bodyText = await res.text().catch(() => ""); - return { ok: false, status: res.status, bodyText }; - } - return { ok: true, status: res.status, data: await res.json() }; - } finally { - clearTimeout(timer); - if (signal) signal.removeEventListener("abort", onExternalAbort); - } -} - -/** - * Map the protocol's outcome vocabulary (APPROVE / REJECT / REVIEW / ESCALATE, - * plus rule-action forms) onto the verdict vocabulary the gate logic consumes - * (allow / block / review / escalate). Unmapped strings pass through β€” the - * downstream sets already understand deny/denied/restrain/etc. - */ -const VERDICT_ALIASES = { - approve: "allow", - approved: "allow", - auto_approve: "allow", - reject: "block", - rejected: "block", - auto_reject: "block", - require_review: "review", - request_info: "review", -}; - -export function normalizeVerdict(raw) { - const d = String(raw ?? "") - .trim() - .toLowerCase(); - return VERDICT_ALIASES[d] ?? d; -} - -/** - * Extract the verdict fields from an evaluate-decision response body. - * Pure + testable. Tolerates both top-level and dossier-nested shapes. - * `decision` is normalized (e.g. the protocol's APPROVE β†’ allow) so - * shouldExecute/shouldFail work against the live API; `rawDecision` - * preserves the wire value for logs. - */ -export function parseDecisionResponse(data) { - const rawDecision = String(data?.decision ?? data?.outcome ?? "").toLowerCase(); - const decision = normalizeVerdict(rawDecision); - const dossierId = String(data?.dossier_id ?? data?.dossier?.dossier_id ?? ""); - const signature = data?.verification?.signature ?? data?.signature ?? null; - const policyVersion = data?.policy_version ?? data?.dossier?.policy_version ?? null; - const reasonCode = - (Array.isArray(data?.reason_codes) ? data.reason_codes[0] : undefined) ?? - data?.reason_code ?? - null; - return { decision, rawDecision, dossierId, signature, policyVersion, reasonCode }; -} - -/** Request a signed execution grant from Decionis (returns null on any failure). */ -export async function fetchExecutionGrant({ apiBaseUrl, apiKey, body, timeoutMs, logNotice }) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(`${apiBaseUrl}/v1/protocol/execution-grants/issue`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiKey}`, - "content-type": "application/json", - accept: "application/json", - "x-decionis-source": "github_actions", - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - if (!res.ok) { - logNotice(`Execution grant not issued (HTTP ${res.status}).`); - return null; - } - return await res.json(); - } catch (err) { - logNotice(`Execution grant request failed: ${err instanceof Error ? err.message : err}`); - return null; - } finally { - clearTimeout(timer); - } -} diff --git a/src/index.mjs b/src/index.mjs deleted file mode 100644 index e3485c8..0000000 --- a/src/index.mjs +++ /dev/null @@ -1,1076 +0,0 @@ -// @ts-check -// Decionis GitHub Action β€” Node 20 entrypoint. -// Zero deps: uses built-in fetch + node:fs. -// Resolves a Decionis Decision Dossier for the current workflow step and -// fails the step on a `block` (or `escalate`, configurable) verdict. - -import { spawn } from "node:child_process"; -import { appendFile, readFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; -import { isAbsolute, join } from "node:path"; - -import { evaluateDecision, fetchExecutionGrant, parseDecisionResponse } from "./api-client.mjs"; -import { evaluateLocalPolicy } from "./policy-engine.mjs"; - -/** Max policy-file bytes carried inline with a decision (content over this is - * referenced by hash only, never silently dropped). */ -const POLICY_FILE_INLINE_LIMIT = 131072; // 128 KiB - -const FAIL_MODES = new Set(["block", "escalate", "block_or_escalate", "never"]); -const RUN_MODES = new Set(["enforce", "shadow"]); -const LOCAL_EVAL_MODES = new Set(["auto", "off", "strict"]); - -/** Default for the `local-eval` input β€” the one-line switch for fast-path semantics. */ -export const DEFAULT_LOCAL_EVAL = "auto"; -/** Longest we wait for the background pipeline after the command exits. */ -export const SHADOW_GRACE_CAP_MS = 10_000; -/** Small allowance past the request-timeout budget for parsing/publishing. */ -export const SHADOW_GRACE_EXTRA_MS = 2_500; -/** Bound on the best-effort dossier-recording call after a local block. */ -export const BLOCK_RECORD_CAP_MS = 5_000; - -/** Read an Action input (GitHub maps inputs to env vars). */ -function getInput(name, { required = false } = {}) { - // GitHub maps input `api-key` β†’ env `INPUT_API-KEY`: it upper-cases and - // replaces spaces with `_`, but keeps hyphens (matching @actions/core). Read - // that form first, then fall back to the legacy hyphenβ†’`_` form for safety. - const upper = name.replace(/ /g, "_").toUpperCase(); - const value = process.env[`INPUT_${upper}`] || process.env[`INPUT_${upper.replace(/-/g, "_")}`]; - if (value === undefined || value === "") { - if (required) throw new Error(`Decionis: missing required input '${name}'`); - return ""; - } - return value; -} - -/** - * Strip stray wrapping quotes (straight or smart) and surrounding whitespace - * that sneak in when a secret is pasted as `"key"` β€” a credential never - * legitimately contains them, and a smart quote (U+201C…) would otherwise crash - * the Authorization header ("Cannot convert argument to a ByteString"). - */ -function sanitizeCredential(value) { - return value - .replace(/^[\s"'β€œβ€β€˜β€™]+/, "") - .replace(/[\s"'β€œβ€β€˜β€™]+$/, ""); -} - -function getBooleanInput(name, fallback = false) { - const raw = getInput(name).trim().toLowerCase(); - if (raw === "") return fallback; - return raw === "true" || raw === "1" || raw === "yes"; -} - -/** GitHub log commands β€” these render nicely in the Action log. */ -function logGroup(title, body) { - console.log(`::group::${title}`); - console.log(body); - console.log("::endgroup::"); -} -function logError(message) { - console.log(`::error::${message}`); -} -function logWarning(message) { - console.log(`::warning::${message}`); -} -function logNotice(message) { - console.log(`::notice::${message}`); -} - -/** Set a GitHub Action output (file-based, the v2 mechanism). */ -async function setOutput(name, value) { - const outputFile = process.env.GITHUB_OUTPUT; - if (!outputFile) return; // running locally, just no-op - const safe = value === null || value === undefined ? "" : String(value); - // Use heredoc syntax so multiline values are safe. - const delim = `__DECIONIS_${Date.now()}_${Math.random().toString(36).slice(2)}`; - await appendFile(outputFile, `${name}<<${delim}\n${safe}\n${delim}\n`); -} - -/** Write to the run summary (markdown rendered on the run page). */ -async function writeSummary(markdown) { - const summaryFile = process.env.GITHUB_STEP_SUMMARY; - if (!summaryFile) return; - await appendFile(summaryFile, markdown + "\n"); -} - -/** - * Build a minimal payload from the GitHub workflow context when the consumer - * didn't pass one. Honest β€” only includes fields actually present on the run. - */ -function buildPayloadFromContext() { - const env = process.env; - return { - source: "github_actions", - github: { - repository: env.GITHUB_REPOSITORY ?? null, - ref: env.GITHUB_REF ?? null, - sha: env.GITHUB_SHA ?? null, - event_name: env.GITHUB_EVENT_NAME ?? null, - actor: env.GITHUB_ACTOR ?? null, - run_id: env.GITHUB_RUN_ID ?? null, - run_attempt: env.GITHUB_RUN_ATTEMPT ?? null, - workflow: env.GITHUB_WORKFLOW ?? null, - job: env.GITHUB_JOB ?? null, - server_url: env.GITHUB_SERVER_URL ?? "https://github.com", - }, - }; -} - -/** - * Parse the consumer-provided payload string. Accepts JSON object or JSON - * object inside a heredoc-style string. Falls back to context-derived payload - * if the input is blank. - */ -export function resolvePayload(rawInput, contextBuilder = buildPayloadFromContext) { - const trimmed = (rawInput ?? "").trim(); - if (trimmed === "") return contextBuilder(); - try { - const parsed = JSON.parse(trimmed); - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed; - throw new Error("payload JSON must be an object"); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - throw new Error(`Decionis: input 'payload' is not valid JSON object: ${reason}`); - } -} - -/** - * Build the DECIONIS_POLICY.md descriptor that rides along with a decision. - * Pure + testable: the content hash is the version handle β€” a changed file - * yields a new sha256, so each policy revision is distinct and recorded. - * Content over POLICY_FILE_INLINE_LIMIT is referenced by hash only (with - * `truncated: true`) β€” never silently dropped. - */ -export function buildPolicySource(content, { path, ref = null } = {}) { - const text = typeof content === "string" ? content : ""; - const bytes = Buffer.byteLength(text, "utf8"); - const sha256 = createHash("sha256").update(text, "utf8").digest("hex"); - const inline = bytes <= POLICY_FILE_INLINE_LIMIT; - return { - type: "decionis_policy_md", - path: path ?? null, - ref: ref ?? null, - sha256, - bytes, - truncated: !inline, - ...(inline ? { content: text } : {}), - }; -} - -/** - * Read the repo-local policy file (default DECIONIS_POLICY.md at the workspace - * root) and return its descriptor, or null if absent/unreadable. Never throws: - * a missing or broken policy file must not fail the gate. - */ -export async function loadPolicyFile(relPath, { workspace, ref } = {}) { - const rel = (relPath ?? "").trim(); - if (!rel) return null; - const root = workspace ?? process.env.GITHUB_WORKSPACE ?? process.cwd(); - const full = isAbsolute(rel) ? rel : join(root, rel); - try { - const content = await readFile(full, "utf8"); - return buildPolicySource(content, { path: rel, ref: ref ?? null }); - } catch { - return null; - } -} - -/** - * Fold the friendly `action` label into the payload without clobbering an - * explicit `action` field the caller already set. Returns a new object. - */ -export function applyActionLabel(payload, action) { - const label = (action ?? "").trim(); - if (!label) return payload; - if (payload && typeof payload === "object" && "action" in payload) return payload; - return { ...payload, action: label }; -} - -/** - * Decide whether the wrapped `run` command may execute. - * - * This is the enforcing path β€” Decionis runs the command itself, so it cannot - * execute without an authorizing verdict (no skippable `if:`). In `shadow` - * mode the command always runs: shadow observes, it must never change behavior. - * In `enforce` mode only an `allow` verdict permits execution. - */ -export function shouldExecute(decision, runMode) { - if (runMode === "shadow") return true; - return (decision ?? "").toLowerCase() === "allow"; -} - -/** - * Start the gated command through the chosen shell, streaming its output. - * Returns immediately with the child and an `exited` promise so callers can - * run evaluation work concurrently (speculative shadow / async notarization). - */ -function startCommand(command, shell, extraEnv = {}) { - const sh = (shell || "bash").trim() === "sh" ? "sh" : "bash"; - const args = ["-e", "-c", command]; - const child = spawn(sh, args, { stdio: "inherit", env: { ...process.env, ...extraEnv } }); - const exited = new Promise((resolve) => { - child.on("close", (code) => resolve(code ?? 0)); - child.on("error", (err) => { - logError(`Decionis could not run the gated command: ${err.message}`); - resolve(1); - }); - }); - return { child, exited }; -} - -/** Run the gated command to completion (enforcing path convenience). */ -function executeCommand(command, shell, extraEnv = {}) { - return startCommand(command, shell, extraEnv).exited; -} - -/** Sleep helper for bounded grace waits. */ -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** - * Wrap background/observe-only work: any failure becomes a `::notice::` and - * resolves to null. Shadow mode and async notarization must never be able to - * change the step's exit code. - */ -async function swallow(label, promise) { - try { - return await promise; - } catch (err) { - logNotice(`Decionis (non-fatal) ${label}: ${err instanceof Error ? err.message : err}`); - return null; - } -} - -/** - * The single decision table for how a run reaches its verdict β€” the branch - * point for the `local-eval` input. Pure + testable. - * - * act: "local" = a deterministic committed rule verdict gates the run; - * "api" = the blocking evaluate-decision call gates it (v1.8 path). - * api: what happens to the network call when act=local β€” - * "none" (strict: fully offline), "background" (notarize while the - * command runs), "bounded" (notarize with a capped wait), or - * "blocking" when act=api. - * - * `request-grant: true` always forces the blocking path: the signed grant - * must exist before the command's environment is built. - */ -export function planEvaluation({ localOutcome, localEval, requestGrant, hasRunCommand }) { - const deterministic = Boolean(localOutcome?.deterministic); - if (!deterministic || localEval === "off" || requestGrant) { - return { act: "api", api: "blocking" }; - } - if (localEval === "strict") return { act: "local", api: "none" }; - if (localOutcome.outcome === "block") return { act: "local", api: "bounded" }; - return { act: "local", api: hasRunCommand ? "background" : "bounded" }; -} - -/** - * Grace budget for the background pipeline after the command exits: whatever - * remains of the request-timeout budget plus a small parsing allowance, - * capped so a fast command never waits long on a slow API. - */ -export function computeGraceMs(pipelineStartMs, nowMs, timeoutMs) { - const remaining = Math.max(timeoutMs - (nowMs - pipelineStartMs), 0); - return Math.min(remaining + SHADOW_GRACE_EXTRA_MS, SHADOW_GRACE_CAP_MS); -} - -/** - * Timing instrumentation: the speedups from local evaluation and speculative - * execution must be visible in the log, not just real. - */ -export function createTimeline(t0 = performance.now()) { - const events = []; - const fmt = (ms) => (ms >= 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`); - return { - events, - mark(label, detail) { - events.push({ label, detail: detail ?? null, at: performance.now() - t0 }); - }, - render() { - if (events.length === 0) return "Decionis timing β€” no events recorded"; - const parts = events.map( - (e) => `${e.label}${e.detail ? ` ${e.detail}` : ""} +${fmt(e.at)}`, - ); - return `Decionis timing β€” ${parts.join(" Β· ")}`; - }, - }; -} - -/** - * Build the execution-grant issue request body. Pure + testable. The grant - * binds the authorization to this org, dossier, action, and the repo@sha that - * triggered the run, so a target can verify exactly what was authorized. - */ -export function buildGrantRequestBody({ orgId, dossierId, decision, action, audience }) { - const repo = process.env.GITHUB_REPOSITORY ?? null; - const sha = process.env.GITHUB_SHA ?? null; - const subject = repo && sha ? `${repo}@${sha}` : (repo ?? undefined); - return { - org_id: orgId, - dossier_id: dossierId, - outcome: decision, - ...(action ? { action } : {}), - ...(audience ? { audience } : {}), - ...(subject ? { subject } : {}), - }; -} - -/** Decide whether a given verdict should fail the step under the configured mode. */ -export function shouldFail(decision, failOn, runMode) { - if (runMode === "shadow") return false; - const d = (decision ?? "").toLowerCase(); - if (failOn === "never") return false; - if (failOn === "block") return d === "block" || d === "deny" || d === "denied"; - if (failOn === "escalate") return d === "escalate" || d === "review"; - if (failOn === "block_or_escalate") { - return d === "block" || d === "deny" || d === "denied" || d === "escalate" || d === "review"; - } - // Unknown fail-on values are treated as the default (block). - return d === "block" || d === "deny" || d === "denied"; -} - -/** - * Build the canonical public verify URL (with `?sig=` for the OG card). - * When a policy sha256 is provided, the link additionally pins the exact - * repo-policy revision the decision was made under (`&policy=sha256:`) - * β€” an additive query parameter the server is free to ignore. - */ -export function buildVerifyUrl(siteBase, dossierId, signature, { policySha256 } = {}) { - const base = siteBase.replace(/\/$/, ""); - const id = encodeURIComponent(dossierId); - const params = []; - if (signature) params.push(`sig=${encodeURIComponent(signature)}`); - params.push("source=github_actions"); - if (policySha256) params.push(`policy=${encodeURIComponent(`sha256:${policySha256}`)}`); - return `${base}/verify/decision-dossiers/${id}?${params.join("&")}`; -} - -// ───────────────────────── Growth / virality surface ───────────────────────── -// The PR comment, the run summary, and the embeddable badge are the surfaces -// every reviewer and downstream repo sees. They carry a signed verify link plus -// soft attribution back to the action so adoption compounds (each governed repo -// becomes a billboard). Attribution is on by default but a single input turns -// it off for teams that want a bare comment. - -const ACTION_URL = "https://github.com/decionis/govern"; -const BRAND_URL = "https://decionis.com"; -const COMMENT_MARKER = ""; - -/** Map a raw verdict to display theme + shields.io color (no leading #). */ -export function verdictTheme(decision) { - const d = (decision ?? "").toLowerCase(); - if (d === "allow") return { emoji: "βœ…", label: "Allowed", color: "2ea043" }; - if (d === "block" || d === "deny" || d === "denied") - return { emoji: "πŸ›‘", label: "Blocked", color: "d1242f" }; - if (d === "escalate" || d === "review") - return { emoji: "⚠️", label: "Escalate", color: "bf8700" }; - if (d === "restrain" || d === "restrained") - return { emoji: "βœ‹", label: "Restrained", color: "9a6700" }; - return { emoji: "πŸ›‘οΈ", label: decision || "Unknown", color: "6D28D9" }; -} - -/** Verdict shield image URL β€” rendered at the top of the PR comment + summary. */ -export function verdictBadgeUrl(decision) { - const t = verdictTheme(decision); - return `https://img.shields.io/badge/Decionis-${encodeURIComponent( - t.label, - )}-${t.color}?style=for-the-badge&logo=shield&logoColor=white`; -} - -/** - * The embeddable "Governed by Decionis" badge β€” the viral artifact. Devs drop - * it into their own README; every adopting repo links back to the action. - * When a verify URL is available it links to the live signed proof. - */ -export function governedByBadgeMarkdown(linkUrl = ACTION_URL) { - const img = - "https://img.shields.io/badge/Governed%20by-Decionis-6D28D9?logo=shield&logoColor=white"; - return `[![Governed by Decionis](${img})](${linkUrl})`; -} - -/** - * Build the PR comment body. Pure + testable. Carries a hidden marker so the - * comment is updated in place (no comment spam across pushes). - */ -export function buildPrCommentBody({ - decision, - dossierId, - verifyUrl, - policyVersion, - reasonCode, - runMode, - failOn, - actionLabel = "", - showAttribution = true, -}) { - const t = verdictTheme(decision); - const heading = actionLabel - ? `### ${t.emoji} Action gate Β· \`${actionLabel}\` β€” ${t.label}` - : `### ${t.emoji} Governed step β€” ${t.label}`; - const rows = [ - `| **Verdict** | \`${decision || "unknown"}\` |`, - policyVersion ? `| **Policy** | \`${policyVersion}\` |` : "", - reasonCode ? `| **Reason** | \`${reasonCode}\` |` : "", - dossierId ? `| **Dossier** | \`${dossierId}\` |` : "", - ] - .filter(Boolean) - .join("\n"); - - const modeNote = - runMode === "shadow" - ? "> 🟣 **Shadow mode** β€” recorded for review only. This check never fails your build." - : `> Enforcing β€” this check fails the run on \`${failOn}\`.`; - - const lines = [ - COMMENT_MARKER, - `Decionis verdict: ${t.label}`, - "", - heading, - "", - "| | |", - "| --- | --- |", - rows, - "", - verifyUrl - ? `**[πŸ”Ž Verify this decision β†’](${verifyUrl})** β€” signed, tamper-evident proof.` - : "", - "", - modeNote, - ]; - - if (showAttribution) { - lines.push( - "", - "---", - `πŸ›‘οΈ Governed by Decionis β€” ` + - `a runtime guardrail for autonomous AI agents & CI/CD: gate deploys, migrations & ` + - `infra changes before they execute. ` + - `Start in shadow mode in 30s β†’ ` + - `(decionis/govern)`, - ); - } - - return lines.join("\n"); -} - -const GH_HEADERS = (token) => ({ - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", -}); - -/** Find a previously-posted Decionis comment on the PR (by hidden marker). */ -async function findDecionisCommentId(repoFullName, prNumber, githubToken) { - try { - const res = await fetch( - `https://api.github.com/repos/${repoFullName}/issues/${prNumber}/comments?per_page=100`, - { headers: GH_HEADERS(githubToken) }, - ); - if (!res.ok) return null; - const comments = await res.json(); - if (!Array.isArray(comments)) return null; - const existing = comments.find( - (c) => typeof c?.body === "string" && c.body.includes(COMMENT_MARKER), - ); - return existing?.id ?? null; - } catch { - return null; - } -} - -/** - * Upsert the PR comment: update our existing comment in place if present, - * otherwise create one. Sticky-by-marker so re-runs never spam the thread β€” - * a noisy bot is an uninstalled bot. - */ -async function upsertPrComment(repoFullName, prNumber, githubToken, body) { - if (!repoFullName || !prNumber || !githubToken) return false; - try { - const existingId = await findDecionisCommentId(repoFullName, prNumber, githubToken); - const url = existingId - ? `https://api.github.com/repos/${repoFullName}/issues/comments/${existingId}` - : `https://api.github.com/repos/${repoFullName}/issues/${prNumber}/comments`; - const res = await fetch(url, { - method: existingId ? "PATCH" : "POST", - headers: GH_HEADERS(githubToken), - body: JSON.stringify({ body }), - }); - return res.ok; - } catch { - return false; - } -} - -async function maybeCommentPr({ - enabled, - decision, - dossierId, - verifyUrl, - policyVersion, - reasonCode, - runMode, - failOn, - actionLabel, - showAttribution, -}) { - if (!enabled) return; - if (process.env.GITHUB_EVENT_NAME !== "pull_request") return; - const repoFullName = process.env.GITHUB_REPOSITORY; - const eventPath = process.env.GITHUB_EVENT_PATH; - const githubToken = process.env.GITHUB_TOKEN; - if (!repoFullName || !eventPath || !githubToken) return; - let prNumber = null; - try { - const event = JSON.parse(await readFile(eventPath, "utf8")); - prNumber = event?.pull_request?.number ?? event?.number ?? null; - } catch { - return; - } - if (!prNumber) return; - const body = buildPrCommentBody({ - decision, - dossierId, - verifyUrl, - policyVersion, - reasonCode, - runMode, - failOn, - actionLabel, - showAttribution, - }); - await upsertPrComment(repoFullName, prNumber, githubToken, body); -} - -async function main() { - const timeline = createTimeline(); - const actionLabel = getInput("action").trim(); - const failOnRaw = (getInput("fail-on") || "block").trim().toLowerCase(); - const failOn = FAIL_MODES.has(failOnRaw) ? failOnRaw : "block"; - const runModeRaw = (getInput("mode") || "enforce").trim().toLowerCase(); - const runMode = RUN_MODES.has(runModeRaw) ? runModeRaw : "enforce"; - const localEvalRaw = (getInput("local-eval") || DEFAULT_LOCAL_EVAL).trim().toLowerCase(); - const localEval = LOCAL_EVAL_MODES.has(localEvalRaw) ? localEvalRaw : DEFAULT_LOCAL_EVAL; - const commentPr = getBooleanInput("comment-pr", false); - const showAttribution = getBooleanInput("show-attribution", true); - const runCommand = getInput("run"); - const shell = getInput("shell") || "bash"; - const requestGrant = getBooleanInput("request-grant", false); - const grantAudience = getInput("grant-audience").trim(); - const apiBaseUrl = (getInput("api-base-url") || "https://api.decionis.com").replace(/\/$/, ""); - const siteBaseUrl = (getInput("site-base-url") || "https://decionis.com").replace(/\/$/, ""); - const timeoutMs = Number(getInput("request-timeout-ms") || "20000") || 20000; - - // ── Shadow credential grace ───────────────────────────────────────────── - // In shadow mode a missing api-key/org-id/workflow-key must not fail the - // step: installer-injected shadow steps stay inert until secrets exist. - let apiKey = ""; - let orgId = ""; - let workflowKey = ""; - try { - apiKey = sanitizeCredential(getInput("api-key", { required: true })); - orgId = sanitizeCredential(getInput("org-id", { required: true })); - workflowKey = sanitizeCredential(getInput("workflow-key", { required: true })); - } catch (err) { - if (runMode !== "shadow") throw err; - logNotice( - `Decionis shadow mode β€” ${err instanceof Error ? err.message : err}. ` + - "The gate is not configured yet; nothing is recorded and shadow never fails the step.", - ); - if (runCommand) { - await setOutput("executed", "true"); - process.exit(await executeCommand(runCommand, shell)); - } - process.exit(0); - } - - const payload = applyActionLabel(resolvePayload(getInput("payload")), actionLabel); - - // DECIONIS_POLICY.md convention: read the repo-local policy file (default - // `DECIONIS_POLICY.md` at the workspace root) and inject it into the decision - // so the gate evaluates with β€” and the dossier records β€” your repo's policy. - // A YAML file is also accepted: with the default path, `.yaml`/`.yml` - // siblings are tried too. Set `policy-file: ""` to disable. Missing/unreadable - // file never fails the gate. - const policyFilePath = getInput("policy-file").trim(); - const policyEnforce = getBooleanInput("policy-enforce", false); - const policyCandidates = - policyFilePath === "DECIONIS_POLICY.md" - ? ["DECIONIS_POLICY.md", "DECIONIS_POLICY.yaml", "DECIONIS_POLICY.yml"] - : policyFilePath - ? [policyFilePath] - : []; - let policySource = null; - for (const candidate of policyCandidates) { - policySource = await loadPolicyFile(candidate, { ref: process.env.GITHUB_SHA ?? null }); - if (policySource) break; - } - if (policySource) { - // Opt-in GitOps enforcement: compiles the file's structured `decionis` - // rules block into an ACTIVE enforced policy bundle server-side. - if (policyEnforce) policySource.enforce = true; - payload.decionis_policy = policySource; - await setOutput("policy-sha256", policySource.sha256); - await setOutput("policy-path", policySource.path ?? ""); - await setOutput("policy-enforced", policyEnforce ? "true" : "false"); - logGroup( - "Decionis policy file", - `path=${policySource.path} sha256=${policySource.sha256} bytes=${policySource.bytes}${policySource.truncated ? " (referenced by hash; over inline limit)" : ""}`, - ); - } - - // ── Local policy engine ───────────────────────────────────────────────── - // Evaluate the committed ```decionis rules block in-process (microseconds). - // The engine mirrors the server evaluator and yields a deterministic - // verdict only for an explicitly matched allow/block rule; everything else - // reports a fallback reason and defers to the API. - let localOutcome = null; - if (localEval !== "off" && policySource) { - if (policySource.truncated) { - logNotice("Decionis local engine skipped β€” the policy file exceeds the inline size limit."); - } else if (/\.ya?ml$/i.test(policySource.path ?? "")) { - logNotice("Decionis local engine skipped β€” YAML policy files are evaluated by the API."); - } else { - localOutcome = evaluateLocalPolicy(policySource.content, { payload, orgId, workflowKey }); - timeline.mark( - "local eval", - `${localOutcome.status}${localOutcome.outcome ? ` '${localOutcome.outcome}'` : ""} in ${localOutcome.elapsedUs}Β΅s`, - ); - logGroup( - "Decionis local policy engine", - [ - `status=${localOutcome.status} outcome=${localOutcome.outcome ?? "β€”"} deterministic=${localOutcome.deterministic}`, - `rules=${localOutcome.ruleCount} elapsed=${localOutcome.elapsedUs}Β΅s local-eval=${localEval}`, - localOutcome.matchedRule - ? `rule: #${localOutcome.matchedRule.index + 1} "${localOutcome.matchedRule.name}" β†’ ${localOutcome.matchedRule.action}` - : "", - localOutcome.fallbackReason ? `fallback: ${localOutcome.fallbackReason}` : "", - localOutcome.explanation ? `why: ${localOutcome.explanation}` : "", - ] - .filter(Boolean) - .join("\n"), - ); - } - } - - const plan = planEvaluation({ - localOutcome, - localEval, - requestGrant, - hasRunCommand: Boolean(runCommand), - }); - if (plan.act === "local") { - logNotice( - `⚑ Decionis local verdict '${localOutcome.outcome}' via rule "${localOutcome.matchedRule.name}" ` + - `in ${(localOutcome.elapsedUs / 1000).toFixed(2)}ms β€” API roundtrip ` + - `${plan.api === "none" ? "skipped (local-eval: strict)" : "moved off the critical path"}.`, - ); - } - - const requestBody = { - org_id: orgId, - workflow_key: workflowKey, - payload, - mode: runMode === "shadow" ? "SHADOW" : "ENFORCE", - source: "github_actions", - }; - - // ── Reporting state ────────────────────────────────────────────────────── - // Single-writer discipline: finalizeReport() is the only place that writes - // decision outputs, the (append-only) step summary, and the PR comment, and - // it runs exactly once per run β€” background work only mutates `report`. - const report = { - decision: "", - source: "", - dossierId: "", - signature: null, - policyVersion: null, - reasonCode: null, - mismatch: false, - finalized: false, - }; - if (plan.act === "local") { - report.decision = localOutcome.outcome; - report.source = "local"; - } - - const abortController = new AbortController(); - - const callApi = async (boundMs = timeoutMs) => { - logGroup( - "Decionis evaluate-decision request", - JSON.stringify( - { - url: `${apiBaseUrl}/v1/protocol/evaluate-decision`, - org_id: orgId, - workflow_key: workflowKey, - mode: requestBody.mode, - fail_on: failOn, - local_eval: localEval, - evaluation_plan: `${plan.act}/${plan.api}`, - payload_keys: Object.keys(payload), - }, - null, - 2, - ), - ); - const startedAt = performance.now(); - const response = await evaluateDecision({ - apiBaseUrl, - apiKey, - body: requestBody, - timeoutMs: boundMs, - signal: abortController.signal, - }); - if (!response.ok) { - const error = new Error(`Decionis evaluate-decision failed (${response.status})`); - // @ts-ignore β€” carried for the enforce-path error log - error.status = response.status; - // @ts-ignore - error.bodyText = response.bodyText; - throw error; - } - const parsed = parseDecisionResponse(response.data); - timeline.mark("API verdict", `'${parsed.decision}' in ${Math.round(performance.now() - startedAt)}ms`); - return parsed; - }; - - const adoptApiVerdict = (parsed, { authoritative }) => { - report.dossierId = parsed.dossierId; - report.signature = parsed.signature; - report.policyVersion = parsed.policyVersion; - report.reasonCode = parsed.reasonCode; - if (authoritative) { - report.decision = parsed.decision; - report.source = "api"; - } - if (localOutcome?.deterministic && parsed.decision && parsed.decision !== localOutcome.outcome) { - report.mismatch = true; - logWarning( - `Decionis verdict mismatch β€” local rule verdict '${localOutcome.outcome}'${authoritative ? "" : " (already acted on)"} ` + - `vs API verdict '${parsed.decision}'. The signed dossier records the API verdict; ` + - "review org-level policy or set local-eval: off for this workflow.", - ); - } - }; - - const finalizeReport = async () => { - if (report.finalized) return; - report.finalized = true; - const verifyUrl = report.dossierId - ? buildVerifyUrl(siteBaseUrl, report.dossierId, report.signature, { - policySha256: policySource?.sha256, - }) - : ""; - const badgeMarkdown = governedByBadgeMarkdown(verifyUrl || ACTION_URL); - await Promise.all([ - setOutput("decision", report.decision), - setOutput("decision-source", report.source), - setOutput("dossier-id", report.dossierId), - setOutput("verify-url", verifyUrl), - setOutput("policy-version", report.policyVersion ?? ""), - setOutput("reason-code", report.reasonCode ?? ""), - setOutput("badge-markdown", badgeMarkdown), - setOutput("verdict-mismatch", report.mismatch ? "true" : "false"), - ]); - - const theme = verdictTheme(report.decision); - await writeSummary( - [ - `## ${theme.emoji} Decionis Action Gate${actionLabel ? ` Β· \`${actionLabel}\`` : ""}: ${theme.label}`, - "", - `Decionis verdict: ${theme.label}`, - "", - "| | |", - "| --- | --- |", - `| **Verdict** | \`${report.decision || "unknown"}\` |`, - report.source === "local" - ? `| **Source** | ⚑ local rule "${localOutcome.matchedRule.name}" in ${localOutcome.elapsedUs}Β΅s |` - : report.source - ? `| **Source** | \`${report.source}\` |` - : "", - `| **Policy** | \`${report.policyVersion ?? "β€”"}\` |`, - report.reasonCode ? `| **Reason** | \`${report.reasonCode}\` |` : "", - report.dossierId ? `| **Dossier** | \`${report.dossierId}\` |` : "", - `| **Mode** | \`${runMode}\`${runMode === "shadow" ? " β€” never fails the build" : ` Β· fail-on \`${failOn}\``} |`, - "", - report.mismatch - ? "> ⚠️ The API verdict differed from the local rule verdict (`verdict-mismatch=true`) β€” review org-level policy." - : "", - verifyUrl - ? `**[πŸ”Ž Verify this decision β†’](${verifyUrl})** β€” signed, tamper-evident proof${policySource ? ", pinned to this repo's policy revision" : ""}.` - : "", - "", - "
πŸ“Œ Add the β€œGoverned by Decionis” badge to your README", - "", - "```markdown", - governedByBadgeMarkdown(), - "```", - "", - `Gate your own deploys, releases, and infra changes β†’ [**decionis/govern**](${ACTION_URL})`, - "
", - ] - .filter(Boolean) - .join("\n"), - ); - - if (report.decision) { - logNotice( - `Decionis verdict: ${report.decision} (${report.source}${report.dossierId ? `, dossier ${report.dossierId}` : ""})`, - ); - } - - await maybeCommentPr({ - enabled: commentPr, - decision: report.decision, - dossierId: report.dossierId, - verifyUrl, - policyVersion: report.policyVersion, - reasonCode: report.reasonCode, - runMode, - failOn, - actionLabel, - showAttribution, - }); - }; - - // ── Branch A: speculative shadow ───────────────────────────────────────── - // The verdict can never change behavior in shadow, so the command starts - // IMMEDIATELY and the whole evaluation pipeline runs in the background. - // The step's exit code is exactly the command's exit code β€” evaluation - // failures are notices, never errors. - if (runMode === "shadow" && runCommand && !requestGrant) { - const { exited } = startCommand(runCommand, shell, {}); - timeline.mark("command started"); - logNotice( - "🟣 Decionis shadow mode β€” command started immediately; the verdict resolves in the background.", - ); - await setOutput("executed", "true"); - const pipelineStart = performance.now(); - const pipeline = swallow( - "shadow evaluation", - (async () => { - if (plan.act === "local" && plan.api === "none") return; // strict: fully offline - adoptApiVerdict(await callApi(), { authoritative: true }); - })(), - ); - const code = await exited; - timeline.mark("command exited", `code ${code}`); - const graceMs = computeGraceMs(pipelineStart, performance.now(), timeoutMs); - const pending = await Promise.race([ - pipeline.then(() => false), - sleep(graceMs).then(() => true), - ]); - if (pending) { - abortController.abort(); - logNotice( - `Decionis shadow verdict still pending after the ${Math.round(graceMs)}ms grace window β€” completing with the command's exit code.`, - ); - } - await finalizeReport(); - logNotice(timeline.render()); - process.exit(code); - } - - // ── Branch B: shadow, verdict-only ─────────────────────────────────────── - if (runMode === "shadow" && !runCommand && !requestGrant) { - await swallow( - "shadow evaluation", - (async () => { - if (plan.act === "local" && plan.api === "none") return; - adoptApiVerdict(await callApi(), { authoritative: true }); - })(), - ); - await finalizeReport(); - logNotice(timeline.render()); - return; // shadow never fails β€” not even on API errors - } - - // ── Branch C/D: enforce, gated by a deterministic local verdict ────────── - if (plan.act === "local" && runMode === "enforce") { - if (localOutcome.outcome === "block") { - // Best-effort dossier recording, tightly bounded: nothing is waiting to - // run, but the block should still leave a signed audit trail. - if (plan.api === "bounded") { - const parsed = await swallow( - "dossier recording", - callApi(Math.min(timeoutMs, BLOCK_RECORD_CAP_MS)), - ); - if (parsed) adoptApiVerdict(parsed, { authoritative: false }); - } - await finalizeReport(); - logNotice(timeline.render()); - if (runCommand) { - await setOutput("executed", "false"); - logError( - `Decionis BLOCKED execution locally (rule "${localOutcome.matchedRule.name}", mode=${runMode}). ` + - `The gated command was NOT run.${report.dossierId ? ` Dossier: ${report.dossierId}` : ""}`, - ); - process.exit(1); - } - if (shouldFail("block", failOn, runMode)) { - logError( - `Decionis blocked this step locally (verdict=block, rule "${localOutcome.matchedRule.name}", fail-on=${failOn}).`, - ); - process.exit(1); - } - return; - } - - // Local allow. - if (runCommand) { - logNotice("Decionis authorized execution locally β€” running the gated command."); - if (plan.api === "none") { - await finalizeReport(); - await setOutput("executed", "true"); - timeline.mark("command started"); - const code = await executeCommand(runCommand, shell, {}); - timeline.mark("command exited", `code ${code}`); - logNotice(timeline.render()); - process.exit(code); - } - // auto: start the command now, notarize while it runs. - const { exited } = startCommand(runCommand, shell, {}); - timeline.mark("command started"); - await setOutput("executed", "true"); - const pipelineStart = performance.now(); - const pipeline = swallow( - "notarization", - (async () => { - adoptApiVerdict(await callApi(), { authoritative: false }); - })(), - ); - const code = await exited; - timeline.mark("command exited", `code ${code}`); - const graceMs = computeGraceMs(pipelineStart, performance.now(), timeoutMs); - const pending = await Promise.race([ - pipeline.then(() => false), - sleep(graceMs).then(() => true), - ]); - if (pending) { - abortController.abort(); - logNotice( - `Decionis notarization still pending after the ${Math.round(graceMs)}ms grace window β€” completing with the command's exit code.`, - ); - } - await finalizeReport(); - logNotice(timeline.render()); - process.exit(code); - } - - // Local allow, verdict-only: notarize with a bounded wait, never fail. - if (plan.api !== "none") { - const parsed = await swallow("notarization", callApi()); - if (parsed) adoptApiVerdict(parsed, { authoritative: false }); - } - await finalizeReport(); - logNotice(timeline.render()); - return; // an allow verdict never fails the step - } - - // ── Branch E: blocking API path (v1.8 semantics) ───────────────────────── - // Reached on: local-eval off, no/indeterminate local verdict, matched - // escalate/restrain, request-grant, or YAML/truncated/absent policy files. - let apiVerdict; - try { - apiVerdict = await callApi(); - } catch (err) { - if (runMode === "shadow") { - logNotice( - `Decionis shadow mode β€” evaluation unavailable (${err instanceof Error ? err.message : err}); shadow never fails the step.`, - ); - await finalizeReport(); - if (runCommand) { - await setOutput("executed", "true"); - process.exit(await executeCommand(runCommand, shell)); - } - return; - } - // @ts-ignore β€” status/bodyText attached by callApi for HTTP failures - if (err?.status) logError(`Decionis API returned ${err.status}: ${String(err.bodyText ?? "").slice(0, 600)}`); - throw err; - } - adoptApiVerdict(apiVerdict, { authoritative: true }); - await finalizeReport(); - - // ── Execution Grant (Governor Layer) ──────────────────────────────────── - // On an authorizing verdict, request a short-lived signed grant. Targets - // verify it before acting, so execution can't proceed without a Decionis - // verdict even if the workflow gate is bypassed. - const grantEnv = {}; - if (requestGrant && report.decision === "allow" && report.dossierId) { - const grant = await fetchExecutionGrant({ - apiBaseUrl, - apiKey, - timeoutMs, - logNotice, - body: buildGrantRequestBody({ - orgId, - dossierId: report.dossierId, - decision: report.decision, - action: actionLabel, - audience: grantAudience, - }), - }); - if (grant?.execution_grant) { - await setOutput("execution-grant", grant.execution_grant); - await setOutput("grant-expires-at", grant.expires_at ?? ""); - grantEnv.DECIONIS_EXECUTION_GRANT = grant.execution_grant; - grantEnv.DECIONIS_GRANT_VERIFY_URL = `${apiBaseUrl}/v1${grant.jwks_url ?? "/.well-known/decionis-execution-grant-jwks.json"}`; - logNotice(`Execution grant issued (expires ${grant.expires_at ?? "soon"}).`); - } - } - - // ── Enforcing path ────────────────────────────────────────────────────── - // When a `run` command is supplied, Decionis OWNS the execution: the command - // runs through this Action, so it cannot execute without an authorizing - // verdict. There is no skippable `if:` to delete. The grant (if any) is in - // the command's env as DECIONIS_EXECUTION_GRANT for the target to verify. - if (runCommand) { - const authorized = shouldExecute(report.decision, runMode); - await setOutput("executed", authorized ? "true" : "false"); - if (!authorized) { - logError( - `Decionis BLOCKED execution (verdict=${report.decision}, mode=${runMode}). The gated command was NOT run. Dossier: ${report.dossierId}`, - ); - process.exit(1); - } - logNotice( - runMode === "shadow" - ? "Decionis shadow mode β€” running the gated command (observe-only)." - : "Decionis authorized execution β€” running the gated command.", - ); - timeline.mark("command started"); - const code = await executeCommand(runCommand, shell, grantEnv); - timeline.mark("command exited", `code ${code}`); - logNotice(timeline.render()); - process.exit(code); - } - - // ── Advisory path (no `run`) ──────────────────────────────────────────── - // Verdict-only: sets outputs for a downstream `if:`. Note this guard is - // advisory and can be removed β€” prefer the `run` wrapper above to enforce. - if (shouldFail(report.decision, failOn, runMode)) { - logError( - `Decionis blocked this step (verdict=${report.decision}, fail-on=${failOn}, mode=${runMode}). Dossier: ${report.dossierId}`, - ); - process.exit(1); - } - logNotice(timeline.render()); -} - -const isMain = (() => { - if (!process.argv[1]) return false; - try { - return import.meta.url === new URL(process.argv[1], "file://").href; - } catch { - return false; - } -})(); - -if (isMain) { - main().catch((err) => { - logError(`Decionis Action failed: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); - }); -} diff --git a/src/policy-engine.mjs b/src/policy-engine.mjs deleted file mode 100644 index cd712aa..0000000 --- a/src/policy-engine.mjs +++ /dev/null @@ -1,646 +0,0 @@ -// @ts-check -// Decionis local policy engine β€” parses the fenced ```decionis rules block of -// a DECIONIS_POLICY.md file and evaluates it against a decision payload -// entirely in-process (microseconds, no network). -// -// HOST-AGNOSTIC BY DESIGN: no process.env, no fs, no network, no node:crypto β€” -// pure functions over strings and plain objects, so the same module can serve -// the GitHub Action, the platform MCP server, or a future WASM build. -// -// FAITHFUL MIRROR: extraction, validation, fact building, field resolution, -// operator semantics (including value coercion), rule ordering, and -// first-match selection replicate the platform's compiler -// (apps/api/src/services/policyEncoding/decionisRulesBlock.ts), request -// normalizer (apps/api/src/services/protocolAdapter/normalizeEvaluateBody.ts), -// and evaluator (apps/protocol/src/app.ts β€” buildEvaluateDecisionPolicyFacts / -// resolveEvaluateDecisionPolicyFactValue / evaluateDecisionPolicyPredicate / -// evaluateDecisionPolicyGraph). Divergence here is a bug: when in doubt this -// engine must return a fallback, never a guess. -// -// SAFETY MODEL: a deterministic verdict is returned ONLY when an explicitly -// committed allow/block rule matches using facts that are fully knowable -// client-side. Facts only the server can know (decision domain taxonomy, -// rollout-effective mode, selected bundle metadata) are marked UNKNOWN; -// any rule whose outcome could hinge on them halts the scan and defers to -// the API. Rules that definitively do NOT match are skipped regardless β€” -// a non-matching rule contributes nothing on either side. - -/** Operators accepted by the platform compiler (OP_MAP keys, lowercase). */ -export const SUPPORTED_OPS = [ - "eq", - "ne", - "neq", - "gt", - "gte", - "lt", - "lte", - "in", - "not_in", - "exists", - "contains", - "matches", -]; - -/** Sentinel for facts the server derives from state we cannot see client-side. */ -export const UNKNOWN_FACT = Object.freeze({ __decionis_unknown_fact__: true }); - -const OP_MAP = { - eq: "EQ", - ne: "NEQ", - neq: "NEQ", - gt: "GT", - gte: "GTE", - lt: "LT", - lte: "LTE", - in: "IN", - not_in: "NOT_IN", - exists: "EXISTS", - contains: "CONTAINS", - matches: "MATCHES", -}; -const VALUELESS_OPS = new Set(["EXISTS"]); -const VALUES_OPS = new Set(["IN", "NOT_IN"]); -const ACTIONS = new Set(["allow", "block", "restrain", "escalate"]); - -const isPlainObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v); -/** Mirror of the server's asRecord: non-objects coerce to an empty record. */ -const asRecord = (v) => (isPlainObject(v) ? v : {}); - -// ───────────────────────────── extraction ───────────────────────────── - -/** - * Extract the raw text inside the first ```decionis fenced block. Mirrors the - * platform compiler's extractDecionisRulesBlock verbatim (same regex): the - * FIRST block wins, the info string may carry an optional json/yaml tag, and - * a missing/empty block means "nothing to enforce". - * - * @param {string} markdown - * @returns {string | null} - */ -export function extractDecionisBlock(markdown) { - if (typeof markdown !== "string") return null; - const re = /```decionis(?:\s+(?:json|ya?ml))?[ \t]*\r?\n([\s\S]*?)\r?\n```/i; - const m = re.exec(markdown); - return m ? (m[1] ?? "").trim() || null : null; -} - -// ───────────────────────────── compilation ───────────────────────────── - -/** - * Parse + validate a rules block exactly like the platform compiler - * (compileDecionisRules): all-or-nothing, unknown extra keys ignored, - * `priority` / `domain` / `rationale` honored, `version` ignored entirely. - * The one client-side difference: the server parses the block with a YAML 1.2 - * reader (JSON superset). We are zero-dependency, so a block that is not - * valid JSON yields `{ok:false, reason:"not_json"}` β€” the caller must fall - * back to the API (which may still compile it as YAML), never reject it. - * - * @param {string} blockText - * @param {{workflowKey?: string | null}} [opts] - * @returns {{ok: true, rules: Array} | {ok: false, reason: string, errors?: string[]}} - * - * CompiledRule = { name: string, quantifier: "all"|"any", - * predicates: Array<{field: string, operator: string, value?: any, values?: any[]}>, - * action: "allow"|"block"|"restrain"|"escalate", - * domain: string, priority: number, originalIndex: number } - */ -export function parsePolicy(blockText, opts = {}) { - let parsed; - try { - parsed = JSON.parse(blockText); - } catch { - return { ok: false, reason: "not_json" }; - } - - const errors = []; - if (!isPlainObject(parsed)) { - return { ok: false, reason: "compile_error", errors: ["decionis block must be a JSON object"] }; - } - const rawRules = parsed.rules; - if (!Array.isArray(rawRules) || rawRules.length === 0) { - return { - ok: false, - reason: "compile_error", - errors: ['decionis block needs a non-empty "rules" array'], - }; - } - - const domainDefault = (opts.workflowKey ?? "").trim() || "github_action"; - const rules = []; - - rawRules.forEach((raw, i) => { - const path = `rules[${i}]`; - if (!isPlainObject(raw)) { - errors.push(`${path}: must be an object`); - return; - } - const r = raw; - const name = typeof r.name === "string" && r.name.trim() ? r.name.trim() : ""; - if (!name) errors.push(`${path}: needs a non-empty "name"`); - - const actionRaw = typeof r.action === "string" ? r.action.trim().toLowerCase() : ""; - const action = ACTIONS.has(actionRaw) ? actionRaw : null; - if (!action) { - errors.push( - `${path}: unknown action "${r.action}" (allowed: allow, block, restrain, escalate)`, - ); - } - - const hasAll = Array.isArray(r.all) && r.all.length > 0; - const hasAny = Array.isArray(r.any) && r.any.length > 0; - if (hasAll === hasAny) { - errors.push(`${path}: provide exactly one of "all" or "any" with at least one condition`); - } - - const groupKey = hasAll ? "all" : "any"; - const rawPredicates = hasAll ? r.all : r.any; - const predicates = []; - (Array.isArray(rawPredicates) ? rawPredicates : []).forEach((pred, j) => { - const compiled = compilePredicate(pred, `${path}.${groupKey}[${j}]`, errors); - if (compiled) predicates.push(compiled); - }); - - if (!name || !action || hasAll === hasAny || predicates.length === 0) return; - - const priority = - typeof r.priority === "number" && Number.isFinite(r.priority) - ? Math.max(0, Math.min(100000, Math.round(r.priority))) - : i; - - rules.push({ - name, - quantifier: groupKey, - predicates, - action, - domain: typeof r.domain === "string" && r.domain.trim() ? r.domain.trim() : domainDefault, - priority, - originalIndex: i, - }); - }); - - if (errors.length > 0) return { ok: false, reason: "compile_error", errors }; - return { ok: true, rules }; -} - -/** Mirror of the compiler's compilePredicate. Errors accumulate; null on failure. */ -function compilePredicate(raw, path, errors) { - if (!isPlainObject(raw)) { - errors.push(`${path}: predicate must be an object`); - return null; - } - const field = typeof raw.field === "string" ? raw.field.trim() : ""; - if (!field) { - errors.push(`${path}: predicate needs a non-empty "field"`); - return null; - } - const opRaw = typeof raw.op === "string" ? raw.op.trim().toLowerCase() : ""; - const operator = OP_MAP[opRaw]; - if (!operator) { - errors.push(`${path}: unknown operator "${raw.op}" (allowed: ${Object.keys(OP_MAP).join(", ")})`); - return null; - } - const out = { field, operator }; - if (VALUELESS_OPS.has(operator)) return out; // EXISTS: any provided value is dropped, as on the server - if (VALUES_OPS.has(operator)) { - if (!Array.isArray(raw.values) || raw.values.length === 0) { - errors.push(`${path}: operator "${opRaw}" requires a non-empty "values" array`); - return null; - } - out.values = raw.values; - return out; - } - if (!("value" in raw)) { - errors.push(`${path}: operator "${opRaw}" requires a "value"`); - return null; - } - out.value = raw.value; - return out; -} - -// ───────────────────────────── facts ───────────────────────────── - -/** - * Reconstruct the facts object the server evaluates rules against, for a - * decision submitted by this Action. Mirrors normalizeEvaluateDecisionBody - * (payload merges into context; decision_type derives from context.action β†’ - * workflow_key β†’ "action_gate"), the handler's context enrichment, and - * buildEvaluateDecisionPolicyFacts β€” including its quirk that explicit - * request-level keys (amount, risk_score, …) OVERWRITE same-named payload - * keys at the facts root with undefined when the Action didn't send them. - * - * Facts only the server can compute are set to UNKNOWN_FACT: - * - decision_domain (vertical-pack taxonomy resolution) - * - mode (org rollout policy may override the requested mode) - * - objective_profile / requested_policy_version (schema & pack defaults) - * - context.vertical_pack (resolved pack key) - * - policy (selected bundle id/version) - */ -export function buildEvaluationFacts(payload, { orgId, workflowKey } = {}) { - const p = isPlainObject(payload) ? payload : {}; - const context = { ...p, vertical_pack: UNKNOWN_FACT, workflow_key: workflowKey ?? null }; - const signals = asRecord(context.signals); - const override = asRecord(context.override); - const evidence = asRecord(context.evidence); - const attachments = Array.isArray(context.attachments) - ? context.attachments - : Array.isArray(evidence.attachments) - ? evidence.attachments - : []; - - // normalizeEvaluateDecisionBody: derive decision_type (Action never sends one). - const candidates = [ - typeof p.action === "string" ? p.action : null, - typeof workflowKey === "string" ? workflowKey : null, - ]; - const derived = candidates.find((c) => typeof c === "string" && c.trim() !== ""); - const decisionType = (derived ?? "action_gate").toString().trim().slice(0, 120); - - return { - ...context, - org_id: orgId, - decision_type: decisionType, - decision_domain: UNKNOWN_FACT, - amount: undefined, - risk_score: undefined, - channel: undefined, - objective_profile: UNKNOWN_FACT, - mode: UNKNOWN_FACT, - decision_band: undefined, - transaction_type: undefined, - workflow_key: workflowKey ?? undefined, - vertical_pack: undefined, - requested_policy_version: UNKNOWN_FACT, - context, - intake: context, - signals, - override, - evidence, - attachments, - attachment_count: attachments.length, - policy: UNKNOWN_FACT, - }; -} - -/** - * Resolve a field against the facts, mirroring the server's - * resolveEvaluateDecisionPolicyFactValue (exact root key first, then - * dot/bracket path traversal; arrays index numerically; non-objects coerce to - * empty records). Returns {kind:"unknown"} the moment traversal touches an - * UNKNOWN_FACT β€” that value exists server-side but is not knowable here. - * - * @returns {{kind: "value", value: any} | {kind: "unknown"}} - */ -export function resolveFactValue(facts, field) { - if (Object.prototype.hasOwnProperty.call(facts, field)) { - const v = facts[field]; - return v === UNKNOWN_FACT ? { kind: "unknown" } : { kind: "value", value: v }; - } - - const normalizedPath = String(field).replace(/\[(\d+)\]/g, ".$1"); - const segments = normalizedPath - .split(".") - .map((segment) => segment.trim()) - .filter(Boolean); - if (segments.length === 0) return { kind: "value", value: undefined }; - - let current = facts; - for (const segment of segments) { - if (current === UNKNOWN_FACT) return { kind: "unknown" }; - if (Array.isArray(current)) { - const index = Number.parseInt(segment, 10); - if (!Number.isFinite(index)) return { kind: "value", value: undefined }; - current = current[index]; - continue; - } - current = asRecord(current)[segment]; - } - return current === UNKNOWN_FACT ? { kind: "unknown" } : { kind: "value", value: current }; -} - -// ─────────────────────── value coercion (server-exact) ─────────────────────── - -/** Mirror of the server's asNumber (parseFloat semantics for strings). */ -function asNumber(value) { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim().length > 0) { - const parsed = Number.parseFloat(value); - if (Number.isFinite(parsed)) return parsed; - } - return null; -} - -/** Mirror of parseEvaluateDecisionPolicyBoolean. */ -function asBooleanish(value) { - if (typeof value === "boolean") return value; - if (typeof value === "string") { - const normalized = value.trim().toLowerCase(); - if (normalized === "true") return true; - if (normalized === "false") return false; - } - if (typeof value === "number") { - if (value === 1) return true; - if (value === 0) return false; - } - return null; -} - -/** Mirror of normalizeEvaluateDecisionPolicyText. */ -function asComparableText(value) { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed.toLowerCase() : null; - } - if (typeof value === "number" || typeof value === "boolean") { - return String(value).toLowerCase(); - } - return null; -} - -/** - * Semantic equality, mirroring areEvaluateDecisionPolicyValuesEqual: arrays - * match on any element (both sides, recursively), then numeric coercion, then - * boolean coercion, then case-insensitive text, then strict identity. - */ -export function valuesEqual(actual, expected) { - if (Array.isArray(actual)) { - return actual.some((item) => valuesEqual(item, expected)); - } - if (Array.isArray(expected)) { - return expected.some((item) => valuesEqual(actual, item)); - } - - const actualNumber = asNumber(actual); - const expectedNumber = asNumber(expected); - if (actualNumber != null && expectedNumber != null) return actualNumber === expectedNumber; - - const actualBoolean = asBooleanish(actual); - const expectedBoolean = asBooleanish(expected); - if (actualBoolean != null && expectedBoolean != null) return actualBoolean === expectedBoolean; - - const actualText = asComparableText(actual); - const expectedText = asComparableText(expected); - if (actualText != null && expectedText != null) return actualText === expectedText; - - return actual === expected; -} - -// ───────────────────────────── evaluation ───────────────────────────── - -/** - * Evaluate one compiled predicate, three-valued: `true` / `false` exactly as - * the server's evaluateDecisionPolicyPredicate would, or `"unknown"` when the - * referenced fact is not knowable client-side. - * - * @returns {boolean | "unknown"} - */ -export function evaluatePredicate(predicate, facts) { - const resolved = resolveFactValue(facts, predicate.field); - if (resolved.kind === "unknown") return "unknown"; - const actual = resolved.value; - const expectedValues = Array.isArray(predicate.values) - ? predicate.values - : Object.prototype.hasOwnProperty.call(predicate, "value") - ? [predicate.value] - : []; - - switch (predicate.operator) { - case "EQ": - return valuesEqual(actual, predicate.value); - case "NEQ": - return !valuesEqual(actual, predicate.value); - case "GT": { - const a = asNumber(actual); - const b = asNumber(predicate.value); - return a != null && b != null ? a > b : false; - } - case "GTE": { - const a = asNumber(actual); - const b = asNumber(predicate.value); - return a != null && b != null ? a >= b : false; - } - case "LT": { - const a = asNumber(actual); - const b = asNumber(predicate.value); - return a != null && b != null ? a < b : false; - } - case "LTE": { - const a = asNumber(actual); - const b = asNumber(predicate.value); - return a != null && b != null ? a <= b : false; - } - case "IN": - return expectedValues.some((expected) => valuesEqual(actual, expected)); - case "NOT_IN": - return expectedValues.every((expected) => !valuesEqual(actual, expected)); - case "EXISTS": - // Compiler drops any provided value for EXISTS, so this is pure existence. - return actual !== undefined && actual !== null; - case "CONTAINS": { - if (Array.isArray(actual)) { - return expectedValues.some((expected) => - actual.some((item) => valuesEqual(item, expected)), - ); - } - const actualText = asComparableText(actual); - if (!actualText) return false; - return expectedValues.some((expected) => { - const expectedText = asComparableText(expected); - return expectedText ? actualText.includes(expectedText) : false; - }); - } - case "MATCHES": { - const actualText = asComparableText(actual); - const pattern = typeof predicate.value === "string" ? predicate.value : null; - if (!actualText || !pattern) return false; - try { - return new RegExp(pattern, "i").test(actualText); - } catch { - return false; - } - } - default: - return false; // mirrors the server's default arm; unreachable for compiled rules - } -} - -/** - * Evaluate a rule's condition group under Kleene three-valued logic. - * `all`: any false β†’ false; any unknown (and no false) β†’ unknown; else true. - * `any`: any true β†’ true; any unknown (and no true) β†’ unknown; else false. - * - * @returns {boolean | "unknown"} - */ -export function evaluateRuleConditions(rule, facts) { - let sawUnknown = false; - if (rule.quantifier === "all") { - for (const predicate of rule.predicates) { - const r = evaluatePredicate(predicate, facts); - if (r === false) return false; - if (r === "unknown") sawUnknown = true; - } - return sawUnknown ? "unknown" : true; - } - for (const predicate of rule.predicates) { - const r = evaluatePredicate(predicate, facts); - if (r === true) return true; - if (r === "unknown") sawUnknown = true; - } - return sawUnknown ? "unknown" : false; -} - -/** - * Is this rule guaranteed to survive the server's decision-domain filter? - * The server keeps a rule when its domain is "*" / "ANY" or equals the - * resolved taxonomy domain β€” which we cannot compute client-side. So only - * wildcard domains are certain; anything else is uncertain and matters only - * if the rule would otherwise match. - */ -export function isDomainCertain(rule) { - const normalized = rule.domain.trim().toUpperCase(); - return normalized === "*" || normalized === "ANY"; -} - -function describePredicates(rule, facts) { - const parts = rule.predicates.map((p) => { - const state = evaluatePredicate(p, facts); - const rhs = Object.prototype.hasOwnProperty.call(p, "values") - ? JSON.stringify(p.values) - : JSON.stringify(p.value); - return `${p.field} ${p.operator.toLowerCase()} ${rhs ?? ""} β†’ ${state}`; - }); - return `${rule.quantifier}[ ${parts.join("; ")} ]`; -} - -/** - * First-match scan over the compiled rules in server order (priority DESC, - * original index ASC β€” mirroring sortEvaluateDecisionPolicyRules). - * - * - conditions false β†’ skip (a non-matching rule is inert on both sides) - * - conditions true + wildcard domain β†’ SELECTED - * - conditions true + non-wildcard domain β†’ indeterminate (the server may - * domain-filter this rule; we cannot know, and if kept it would win) - * - conditions unknown β†’ indeterminate (if it matched server-side it wins) - * - exhausted β†’ no_match (server outcome: REVIEW via no_matching_rule) - * - * @returns {{outcome: "allow"|"block"|"restrain"|"escalate"|"no_match"|"indeterminate", - * matchedRule: object|null, haltedRule: object|null, explanation: string}} - */ -export function evaluatePolicy(rules, facts) { - const ordered = [...rules].sort((left, right) => { - if (left.priority !== right.priority) return right.priority - left.priority; - return left.originalIndex - right.originalIndex; - }); - - for (const rule of ordered) { - const conditions = evaluateRuleConditions(rule, facts); - if (conditions === false) continue; - const label = `rule #${rule.originalIndex + 1} "${rule.name}"`; - if (conditions === "unknown") { - return { - outcome: "indeterminate", - matchedRule: null, - haltedRule: rule, - explanation: `${label} depends on server-side facts: ${describePredicates(rule, facts)}`, - }; - } - if (!isDomainCertain(rule)) { - return { - outcome: "indeterminate", - matchedRule: null, - haltedRule: rule, - explanation: - `${label} matches but its domain "${rule.domain}" is not "*" β€” the server's ` + - `domain filter may include or exclude it, so the verdict is not locally decidable`, - }; - } - return { - outcome: rule.action, - matchedRule: rule, - haltedRule: null, - explanation: `${label} matched: ${describePredicates(rule, facts)}`, - }; - } - return { - outcome: "no_match", - matchedRule: null, - haltedRule: null, - explanation: "no rule matched (server would resolve no_matching_rule β†’ REVIEW)", - }; -} - -// ───────────────────────────── facade ───────────────────────────── - -/** - * One-call facade: policy file content + decision inputs β†’ local evaluation. - * - * `deterministic` is true ONLY for an allow/block from an explicitly matched, - * wildcard-domain committed rule evaluated over fully client-knowable facts. - * A matched escalate/restrain is reported as a fallback with the outcome - * attached (a prediction for the API path to confirm). - * - * @param {string} content Raw DECIONIS_POLICY.md text. - * @param {{payload?: object, orgId?: string, workflowKey?: string}} [options] - * @returns {{status: "verdict"|"fallback", outcome: string|null, deterministic: boolean, - * fallbackReason: string|null, matchedRule: {index: number, name: string, action: string}|null, - * explanation: string, ruleCount: number, elapsedUs: number}} - */ -export function evaluateLocalPolicy(content, { payload, orgId, workflowKey } = {}) { - const t0 = performance.now(); - const done = (partial) => ({ - matchedRule: null, - explanation: "", - ruleCount: partial.ruleCount ?? 0, - ...partial, - elapsedUs: Math.max(1, Math.round((performance.now() - t0) * 1000)), - }); - const fallback = (reason, extra = {}) => - done({ - status: "fallback", - outcome: null, - deterministic: false, - fallbackReason: reason, - ...extra, - }); - - const blockText = extractDecionisBlock(content); - if (blockText === null) return fallback("no_rules_block"); - const compiled = parsePolicy(blockText, { workflowKey }); - if (!compiled.ok) { - const detail = compiled.errors?.[0] ? `:${compiled.errors[0]}` : ""; - return fallback(`${compiled.reason}${detail}`); - } - const facts = buildEvaluationFacts(payload, { orgId, workflowKey }); - const result = evaluatePolicy(compiled.rules, facts); - const ruleCount = compiled.rules.length; - - if (result.outcome === "indeterminate") { - return fallback(`rule_indeterminate:${result.haltedRule.name}`, { - explanation: result.explanation, - ruleCount, - }); - } - if (result.outcome === "no_match") return fallback("no_match", { ruleCount }); - - const matchedRule = { - index: result.matchedRule.originalIndex, - name: result.matchedRule.name, - action: result.matchedRule.action, - }; - if (result.outcome === "escalate" || result.outcome === "restrain") { - return fallback(`outcome_not_local:${result.outcome}`, { - outcome: result.outcome, - matchedRule, - explanation: result.explanation, - ruleCount, - }); - } - return done({ - status: "verdict", - outcome: result.outcome, - deterministic: true, - fallbackReason: null, - matchedRule, - explanation: result.explanation, - ruleCount, - }); -} diff --git a/test/helpers.test.mjs b/test/helpers.test.mjs deleted file mode 100644 index aa9a544..0000000 --- a/test/helpers.test.mjs +++ /dev/null @@ -1,310 +0,0 @@ -import { afterEach, describe, it } from "node:test"; -import assert from "node:assert/strict"; - -import { - applyActionLabel, - buildGrantRequestBody, - buildPolicySource, - buildPrCommentBody, - buildVerifyUrl, - governedByBadgeMarkdown, - loadPolicyFile, - resolvePayload, - shouldExecute, - shouldFail, - verdictBadgeUrl, - verdictTheme, -} from "../src/index.mjs"; - -describe("buildPolicySource", () => { - it("hashes content; the sha256 is the version handle", () => { - const a = buildPolicySource("# Policy\nblock prod on fridays\n", { path: "DECIONIS_POLICY.md" }); - assert.equal(a.type, "decionis_policy_md"); - assert.equal(a.path, "DECIONIS_POLICY.md"); - assert.match(a.sha256, /^[0-9a-f]{64}$/); - assert.equal(a.truncated, false); - assert.equal(a.content, "# Policy\nblock prod on fridays\n"); - const same = buildPolicySource("# Policy\nblock prod on fridays\n", {}); - const changed = buildPolicySource("# Policy\nblock prod always\n", {}); - assert.equal(a.sha256, same.sha256); - assert.notEqual(a.sha256, changed.sha256); - }); - - it("references oversized content by hash only, never silently drops it", () => { - const big = "x".repeat(200000); - const s = buildPolicySource(big, { path: "DECIONIS_POLICY.md" }); - assert.equal(s.truncated, true); - assert.equal("content" in s, false); - assert.equal(s.bytes, 200000); - assert.match(s.sha256, /^[0-9a-f]{64}$/); - }); - - it("carries the git ref when provided", () => { - const s = buildPolicySource("policy", { path: "p.md", ref: "abc123" }); - assert.equal(s.ref, "abc123"); - }); -}); - -describe("loadPolicyFile", () => { - it("returns null when the path is empty (disabled)", async () => { - assert.equal(await loadPolicyFile("", { workspace: "/tmp" }), null); - }); - it("returns null (never throws) when the file is absent", async () => { - const r = await loadPolicyFile("DECIONIS_POLICY.md", { - workspace: "/nonexistent-decionis-workspace-xyz", - }); - assert.equal(r, null); - }); -}); - -describe("shouldFail", () => { - it("fails on block when fail-on=block (default)", () => { - assert.equal(shouldFail("block", "block", "enforce"), true); - assert.equal(shouldFail("deny", "block", "enforce"), true); - assert.equal(shouldFail("denied", "block", "enforce"), true); - }); - - it("does NOT fail on escalate when fail-on=block", () => { - assert.equal(shouldFail("escalate", "block", "enforce"), false); - assert.equal(shouldFail("review", "block", "enforce"), false); - }); - - it("fails on escalate / review when fail-on=escalate", () => { - assert.equal(shouldFail("escalate", "escalate", "enforce"), true); - assert.equal(shouldFail("review", "escalate", "enforce"), true); - assert.equal(shouldFail("block", "escalate", "enforce"), false); - }); - - it("fail-on=block_or_escalate covers both", () => { - assert.equal(shouldFail("block", "block_or_escalate", "enforce"), true); - assert.equal(shouldFail("escalate", "block_or_escalate", "enforce"), true); - assert.equal(shouldFail("allow", "block_or_escalate", "enforce"), false); - }); - - it("shadow mode never fails the step regardless of verdict or fail-on", () => { - for (const decision of ["allow", "block", "escalate", "review", "deny"]) { - for (const failOn of ["block", "escalate", "block_or_escalate", "never"]) { - assert.equal(shouldFail(decision, failOn, "shadow"), false, `shadow ${decision}/${failOn}`); - } - } - }); - - it("fail-on=never opts out of all failures", () => { - assert.equal(shouldFail("block", "never", "enforce"), false); - assert.equal(shouldFail("escalate", "never", "enforce"), false); - }); - - it("never fails on an allow verdict, regardless of fail-on", () => { - for (const failOn of ["block", "escalate", "block_or_escalate", "never"]) { - assert.equal(shouldFail("allow", failOn, "enforce"), false); - } - }); -}); - -describe("buildGrantRequestBody", () => { - const prevRepo = process.env.GITHUB_REPOSITORY; - const prevSha = process.env.GITHUB_SHA; - afterEach(() => { - if (prevRepo === undefined) delete process.env.GITHUB_REPOSITORY; - else process.env.GITHUB_REPOSITORY = prevRepo; - if (prevSha === undefined) delete process.env.GITHUB_SHA; - else process.env.GITHUB_SHA = prevSha; - }); - it("binds org, dossier, outcome, action, audience, and repo@sha subject", () => { - process.env.GITHUB_REPOSITORY = "octo/repo"; - process.env.GITHUB_SHA = "deadbeef"; - const body = buildGrantRequestBody({ - orgId: "org-1", - dossierId: "dsr-1", - decision: "allow", - action: "production-deploy", - audience: "prod", - }); - assert.deepEqual(body, { - org_id: "org-1", - dossier_id: "dsr-1", - outcome: "allow", - action: "production-deploy", - audience: "prod", - subject: "octo/repo@deadbeef", - }); - }); - it("omits optional fields when absent", () => { - delete process.env.GITHUB_REPOSITORY; - delete process.env.GITHUB_SHA; - const body = buildGrantRequestBody({ orgId: "o", dossierId: "d", decision: "allow" }); - assert.deepEqual(body, { org_id: "o", dossier_id: "d", outcome: "allow" }); - }); -}); - -describe("shouldExecute (wrapped `run` command)", () => { - it("enforce: only an allow verdict permits execution", () => { - assert.equal(shouldExecute("allow", "enforce"), true); - for (const d of ["block", "deny", "escalate", "review", "restrain", ""]) { - assert.equal(shouldExecute(d, "enforce"), false, `enforce ${d}`); - } - }); - it("shadow: always executes (observe-only, never changes behavior)", () => { - for (const d of ["allow", "block", "escalate", "review", "deny"]) { - assert.equal(shouldExecute(d, "shadow"), true, `shadow ${d}`); - } - }); - it("is case-insensitive on the verdict", () => { - assert.equal(shouldExecute("ALLOW", "enforce"), true); - assert.equal(shouldExecute("BLOCK", "enforce"), false); - }); -}); - -describe("buildVerifyUrl", () => { - it("includes ?sig= and ?source=github_actions for OG-rich unfurls", () => { - const url = buildVerifyUrl("https://decionis.com", "dsr-abc", "sha256:0123"); - assert.equal( - url, - "https://decionis.com/verify/decision-dossiers/dsr-abc?sig=sha256%3A0123&source=github_actions", - ); - }); - - it("omits ?sig= when no signature is available, still attributes the source", () => { - const url = buildVerifyUrl("https://decionis.com", "dsr-abc", null); - assert.equal( - url, - "https://decionis.com/verify/decision-dossiers/dsr-abc?source=github_actions", - ); - }); - - it("URL-encodes dossier ids with special chars", () => { - const url = buildVerifyUrl("https://decionis.com", "dsr/abc#1", null); - assert.match(url, /dsr%2Fabc%231/); - }); - - it("strips a trailing slash from the site base URL", () => { - const url = buildVerifyUrl("https://decionis.com/", "dsr-x", null); - assert.match(url, /^https:\/\/decionis\.com\/verify\/decision-dossiers\/dsr-x/); - }); -}); - -describe("verdictTheme", () => { - it("maps verdict synonyms to a stable label + color", () => { - assert.equal(verdictTheme("allow").label, "Allowed"); - assert.equal(verdictTheme("deny").label, "Blocked"); - assert.equal(verdictTheme("denied").label, "Blocked"); - assert.equal(verdictTheme("review").label, "Escalate"); - assert.equal(verdictTheme("restrained").label, "Restrained"); - }); - it("is case-insensitive and falls back for unknown verdicts", () => { - assert.equal(verdictTheme("BLOCK").label, "Blocked"); - assert.equal(verdictTheme("weird").label, "weird"); - assert.equal(verdictTheme("").label, "Unknown"); - }); -}); - -describe("verdictBadgeUrl", () => { - it("is a shields.io URL carrying the verdict label", () => { - assert.match(verdictBadgeUrl("block"), /^https:\/\/img\.shields\.io\/badge\/Decionis-Blocked-/); - assert.match(verdictBadgeUrl("allow"), /Decionis-Allowed-2ea043/); - }); -}); - -describe("governedByBadgeMarkdown", () => { - it("produces a clickable shields badge linking to the action by default", () => { - const md = governedByBadgeMarkdown(); - assert.match(md, /^\[!\[Governed by Decionis\]\(https:\/\/img\.shields\.io\/badge\/Governed/); - assert.match(md, /\(https:\/\/github\.com\/decionis\/govern\)$/); - }); - it("links to a custom URL (e.g. the live verify URL) when given one", () => { - assert.match( - governedByBadgeMarkdown("https://decionis.com/verify/x"), - /\(https:\/\/decionis\.com\/verify\/x\)$/, - ); - }); -}); - -describe("buildPrCommentBody", () => { - const base = { - decision: "block", - dossierId: "dsr-123", - verifyUrl: "https://decionis.com/verify/decision-dossiers/dsr-123?sig=abc", - policyVersion: "v4.3.0", - reasonCode: "velocity_ceiling_exceeded", - runMode: "enforce", - failOn: "block", - }; - it("includes the hidden sticky marker so re-runs update in place", () => { - assert.match(buildPrCommentBody(base), //); - }); - it("renders the verdict, verify link, policy, and reason", () => { - const body = buildPrCommentBody(base); - assert.match(body, /Governed step β€” Blocked/); - assert.match(body, /Verify this decision β†’/); - assert.match(body, /v4\.3\.0/); - assert.match(body, /velocity_ceiling_exceeded/); - }); - it("shows the shadow-mode note (never fails build) in shadow", () => { - const body = buildPrCommentBody({ ...base, runMode: "shadow" }); - assert.match(body, /Shadow mode/); - assert.match(body, /never fails your build/); - }); - it("includes attribution by default and omits it when disabled", () => { - assert.match(buildPrCommentBody(base), /Governed by .*Decionis/); - const bare = buildPrCommentBody({ ...base, showAttribution: false }); - assert.doesNotMatch(bare, /Governed by { - const body = buildPrCommentBody(base); - assert.match(body, /autonomous AI agents/); - assert.match(body, /shadow mode in 30s/); - assert.match(body, /quickstart\?source=gha_pr_comment/); - }); -}); - -describe("applyActionLabel", () => { - it("folds the action label into the payload", () => { - assert.deepEqual(applyActionLabel({ env: "prod" }, "production-deploy"), { - env: "prod", - action: "production-deploy", - }); - }); - it("does not clobber an explicit payload.action", () => { - assert.deepEqual(applyActionLabel({ action: "explicit" }, "production-deploy"), { - action: "explicit", - }); - }); - it("returns the payload untouched when no label is given", () => { - const p = { env: "prod" }; - assert.equal(applyActionLabel(p, ""), p); - assert.equal(applyActionLabel(p, " "), p); - }); -}); - -describe("buildPrCommentBody β€” action label", () => { - it("surfaces the action label in the heading when provided", () => { - const body = buildPrCommentBody({ - decision: "block", - runMode: "enforce", - failOn: "block", - actionLabel: "production-deploy", - }); - assert.match(body, /Action gate Β· `production-deploy` β€” Blocked/); - }); -}); - -describe("resolvePayload", () => { - it("falls back to a GitHub-context-derived payload when input is blank", () => { - const payload = resolvePayload("", () => ({ source: "github_actions_test", repo: "x/y" })); - assert.equal(payload.source, "github_actions_test"); - assert.equal(payload.repo, "x/y"); - }); - - it("parses a JSON object input as-is", () => { - const payload = resolvePayload('{"environment":"production","blast_radius":"high"}'); - assert.deepEqual(payload, { environment: "production", blast_radius: "high" }); - }); - - it("throws on invalid JSON", () => { - assert.throws(() => resolvePayload("not json"), /not valid JSON/); - }); - - it("throws if payload JSON is an array, not an object", () => { - assert.throws(() => resolvePayload("[1,2,3]"), /must be an object/); - }); -}); diff --git a/test/installer.test.mjs b/test/installer.test.mjs deleted file mode 100644 index f0241da..0000000 --- a/test/installer.test.mjs +++ /dev/null @@ -1,272 +0,0 @@ -// install.sh tests: run the real script under both `sh` and `bash` inside -// throwaway git fixtures. The gh CLI is a PATH shim; pushes go to a local -// bare "origin". PATH is pinned to /usr/bin:/bin so a real gh is never found -// unless the shim provides one. -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { mkdtemp, mkdir, readFile, writeFile, chmod, access } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { evaluateLocalPolicy } from "../src/policy-engine.mjs"; - -const INSTALLER = fileURLToPath(new URL("../install.sh", import.meta.url)); -const BASE_PATH = "/usr/bin:/bin"; - -function exec(cmd, args, { cwd, env = {} } = {}) { - return new Promise((resolve) => { - const child = spawn(cmd, args, { - cwd, - env: { PATH: BASE_PATH, HOME: cwd, GIT_CONFIG_NOSYSTEM: "1", GIT_TERMINAL_PROMPT: "0", ...env }, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (d) => (stdout += d)); - child.stderr.on("data", (d) => (stderr += d)); - child.on("close", (code) => resolve({ code: code ?? 0, stdout, stderr })); - }); -} - -async function git(cwd, ...args) { - const r = await exec("git", args, { cwd }); - assert.equal(r.code, 0, `git ${args.join(" ")} failed: ${r.stderr}`); - return r; -} - -const CI_YML = `name: CI -on: push -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - run: npm test - reusable: - uses: org/repo/.github/workflows/x.yml@main - deploy: - runs-on: ubuntu-latest - steps: - - name: deploy - run: ./deploy.sh -`; - -async function makeRepo({ workflows = {}, commit = true } = {}) { - const dir = await mkdtemp(join(tmpdir(), "govern-install-")); - await git(dir, "init", "-q", "-b", "main"); - await git(dir, "config", "user.email", "test@example.com"); - await git(dir, "config", "user.name", "Test"); - await writeFile(join(dir, "README.md"), "# fixture\n"); - for (const [name, content] of Object.entries(workflows)) { - await mkdir(join(dir, ".github/workflows"), { recursive: true }); - await writeFile(join(dir, ".github/workflows", name), content); - } - if (commit) { - await git(dir, "add", "-A"); - await git(dir, "commit", "-qm", "init"); - } - return dir; -} - -const exists = (p) => - access(p).then( - () => true, - () => false, - ); - -const runInstaller = (shellBin, dir, args = [], env = {}) => - exec(shellBin, [INSTALLER, ...args], { cwd: dir, env }); - -for (const shellBin of ["sh", "bash"]) { - describe(`install.sh under ${shellBin}`, () => { - it("creates the workflow and policy, and is idempotent", async () => { - const dir = await makeRepo(); - const first = await runInstaller(shellBin, dir); - assert.equal(first.code, 0, first.stderr); - assert.match(first.stdout, /created: \.github\/workflows\/decionis-govern\.yml/); - assert.match(first.stdout, /created: DECIONIS_POLICY\.md/); - - const workflow = await readFile(join(dir, ".github/workflows/decionis-govern.yml"), "utf8"); - assert.match(workflow, /uses: decionis\/govern@v1/); - assert.match(workflow, /api-key: \$\{\{ secrets\.DECIONIS_API_KEY \}\}/); - assert.match(workflow, /org-id: \$\{\{ secrets\.DECIONIS_ORG_ID \}\}/); - assert.match(workflow, /mode: shadow/); - assert.match(workflow, /workflow-key: github_pr_change_intent/); - - // Fresh worktree files only; second run is a no-op. - const second = await runInstaller(shellBin, dir); - assert.equal(second.code, 0); - assert.match(second.stdout, /already exists/); - assert.match(second.stdout, /nothing to do/); - }); - - it("generated policy template yields a deterministic local block in the engine", async () => { - const dir = await makeRepo(); - await runInstaller(shellBin, dir); - const policy = await readFile(join(dir, "DECIONIS_POLICY.md"), "utf8"); - const r = evaluateLocalPolicy(policy, { - payload: { change_freeze: true }, - orgId: "org-x", - workflowKey: "github_pr_change_intent", - }); - assert.equal(r.status, "verdict"); - assert.equal(r.outcome, "block"); - assert.equal(r.deterministic, true); - }); - - it("--dry-run writes nothing", async () => { - const dir = await makeRepo({ workflows: { "ci.yml": CI_YML } }); - const r = await runInstaller(shellBin, dir, ["--dry-run", "--inject"]); - assert.equal(r.code, 0, r.stderr); - assert.match(r.stdout, /would create: \.github\/workflows\/decionis-govern\.yml/); - assert.match(r.stdout, /would inject 2 step\(s\) into \.github\/workflows\/ci\.yml/); - assert.equal(await exists(join(dir, "DECIONIS_POLICY.md")), false); - const ci = await readFile(join(dir, ".github/workflows/ci.yml"), "utf8"); - assert.equal(ci, CI_YML); - }); - - it("--inject inserts the shadow step first in each steps job, skipping reusable jobs", async () => { - const dir = await makeRepo({ workflows: { "ci.yml": CI_YML } }); - const r = await runInstaller(shellBin, dir, ["--inject", "--workflow-key", "custom_key"]); - assert.equal(r.code, 0, r.stderr); - assert.match(r.stdout, /injected 2 step\(s\) into \.github\/workflows\/ci\.yml/); - assert.match(r.stdout, /reusable-workflow job skipped/); - - const ci = (await readFile(join(dir, ".github/workflows/ci.yml"), "utf8")).split("\n"); - const stepsIdx = ci.findIndex((l) => l === " steps:"); - assert.equal( - ci[stepsIdx + 1], - " - name: Decionis shadow verdict # decionis-govern (auto)", - "injected as the first step at the existing item indent", - ); - assert.equal(ci[stepsIdx + 2], " uses: decionis/govern@v1"); - assert.equal(ci[stepsIdx + 3], " continue-on-error: true"); - assert.match(ci.join("\n"), /workflow-key: custom_key/); - // The reusable job is untouched. - assert.match(ci.join("\n"), /uses: org\/repo\/\.github\/workflows\/x\.yml@main/); - - // Idempotent: a second --inject skips the file. - const again = await runInstaller(shellBin, dir, ["--inject"]); - assert.match(again.stdout, /skip: +\.github\/workflows\/ci\.yml \(already uses decionis\/govern\)/); - }); - - it("--inject handles 4-space indentation", async () => { - const wide = `on: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n`; - const dir = await makeRepo({ workflows: { "wide.yml": wide } }); - const r = await runInstaller(shellBin, dir, ["--inject"]); - assert.equal(r.code, 0, r.stderr); - const lines = (await readFile(join(dir, ".github/workflows/wide.yml"), "utf8")).split("\n"); - const stepsIdx = lines.findIndex((l) => l === " steps:"); - assert.equal(lines[stepsIdx + 1], " - name: Decionis shadow verdict # decionis-govern (auto)"); - // Step child keys align after the "- " marker: item indent + 2. - assert.equal(lines[stepsIdx + 2], " uses: decionis/govern@v1"); - }); - - it("--inject skips flow-style steps and tabbed files while injecting healthy ones", async () => { - const flow = `on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps: [{ run: echo hi }]\n`; - const tabbed = `on: push\njobs:\n\tb:\n\t\truns-on: ubuntu-latest\n`; - const dir = await makeRepo({ - workflows: { "flow.yml": flow, "tabbed.yml": tabbed, "ok.yml": CI_YML }, - }); - const r = await runInstaller(shellBin, dir, ["--inject"]); - assert.equal(r.code, 0, r.stderr); - assert.match(r.stdout, /skip: +\.github\/workflows\/tabbed\.yml \(contains tabs/); - assert.match(r.stdout, /flow\.yml \(no injectable jobs\)/); - assert.match(r.stdout, /injected 2 step\(s\) into \.github\/workflows\/ok\.yml/); - assert.equal(await readFile(join(dir, ".github/workflows/flow.yml"), "utf8"), flow); - }); - - it("fails with the documented exit codes", async () => { - const bare = await mkdtemp(join(tmpdir(), "govern-notrepo-")); - assert.equal((await runInstaller(shellBin, bare)).code, 2); - - // Git-state gates apply to --pr only; a plain run in a dirty tree is fine. - const dirty = await makeRepo(); - await writeFile(join(dirty, "uncommitted.txt"), "dirt\n"); - assert.equal((await runInstaller(shellBin, dirty, ["--pr"])).code, 4); - const plainDirty = await runInstaller(shellBin, dirty); - assert.equal(plainDirty.code, 0, plainDirty.stderr); - - const detached = await makeRepo(); - await git(detached, "checkout", "-q", "--detach"); - assert.equal((await runInstaller(shellBin, detached, ["--pr"])).code, 3); - - const ok = await makeRepo(); - assert.equal((await runInstaller(shellBin, ok, ["--bogus"])).code, 64); - assert.equal((await runInstaller(shellBin, ok, ["--mode", "yolo"])).code, 64); - }); - }); -} - -describe("install.sh --pr", () => { - async function withOrigin(dir) { - const bare = await mkdtemp(join(tmpdir(), "govern-origin-")); - await git(bare, "init", "-q", "--bare"); - await git(dir, "remote", "add", "origin", bare); - return bare; - } - - it("branches, commits, pushes, and calls gh pr create", async () => { - const dir = await makeRepo({ workflows: { "ci.yml": CI_YML } }); - const bare = await withOrigin(dir); - - const shimDir = await mkdtemp(join(tmpdir(), "govern-shim-")); - const spyFile = join(shimDir, "gh-spy.txt"); - await writeFile(join(shimDir, "gh"), `#!/bin/sh\necho "$@" >> "${spyFile}"\nexit 0\n`); - await chmod(join(shimDir, "gh"), 0o755); - - const r = await exec("sh", [INSTALLER, "--pr", "--inject", "--org-id", "org-123"], { - cwd: dir, - env: { PATH: `${shimDir}:${BASE_PATH}` }, - }); - assert.equal(r.code, 0, r.stderr + r.stdout); - - const spy = await readFile(spyFile, "utf8"); - assert.match(spy, /pr create/); - assert.match(spy, /--base main --head feature\/add-decionis-governance/); - - const branches = (await git(bare, "branch", "--list")).stdout; - assert.match(branches, /feature\/add-decionis-governance/); - - const show = (await git(dir, "show", "--stat", "--oneline", "HEAD")).stdout; - assert.match(show, /add Decionis governance gate/); - assert.match(show, /decionis-govern\.yml/); - assert.match(show, /DECIONIS_POLICY\.md/); - assert.match(show, /ci\.yml/); - - const workflow = await readFile(join(dir, ".github/workflows/decionis-govern.yml"), "utf8"); - assert.match(workflow, /org-id: org-123/, "--org-id inlines a literal id"); - }); - - it("prints the compare URL when gh is unavailable", async () => { - const dir = await makeRepo(); - await git(dir, "remote", "remove", "origin").catch(() => {}); - const bare = await mkdtemp(join(tmpdir(), "govern-origin2-")); - await git(bare, "init", "-q", "--bare"); - // Use a github-style remote URL so the compare link can be derived; the - // push itself goes nowhere, so point origin at the local bare path but - // derive the URL case separately below. - await git(dir, "remote", "add", "origin", bare); - const r = await exec("sh", [INSTALLER, "--pr"], { cwd: dir }); - assert.equal(r.code, 0, r.stderr + r.stdout); - // Local-path origins can't map to a web URL β€” the fallback message appears. - assert.match(r.stdout, /open a PR from feature\/add-decionis-governance/i); - }); - - it("derives GitHub compare URLs from ssh and https remotes", async () => { - // Pure URL-derivation check via the script's sed pipeline, no push: - const ssh = await exec("sh", [ - "-c", - `echo "git@github.com:acme/widgets.git" | sed -e 's/^git@//' -e 's/:/\\//' -e 's/\\.git$//' -e 's/^/https:\\/\\//'`, - ]); - assert.equal(ssh.stdout.trim(), "https://github.com/acme/widgets"); - }); - - it("--pr without an origin remote exits 5", async () => { - const dir = await makeRepo(); - const r = await exec("sh", [INSTALLER, "--pr"], { cwd: dir }); - assert.equal(r.code, 5); - }); -}); diff --git a/test/integration.test.mjs b/test/integration.test.mjs deleted file mode 100644 index 99844f4..0000000 --- a/test/integration.test.mjs +++ /dev/null @@ -1,344 +0,0 @@ -// End-to-end tests: spawn the real action entrypoint with INPUT_* env vars -// against a local mock Decionis API, and assert exit codes, outputs, summary, -// and β€” critically β€” the timing behavior of speculative shadow mode and the -// local-verdict short-circuit. -import { describe, it, after } from "node:test"; -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import http from "node:http"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const INDEX = fileURLToPath(new URL("../src/index.mjs", import.meta.url)); - -const ALLOW_RESPONSE = { - outcome: "APPROVE", - dossier_id: "dsr-int", - verification: { signature: "sig-int" }, - policy_version: "pv-1", - reason_codes: ["ok"], -}; - -const openServers = []; -after(async () => { - await Promise.all(openServers.map((close) => close())); -}); - -/** Start a mock Decionis API. `routes` maps URL suffixes to {delayMs, status, body}. */ -async function startMockApi(routes) { - const requests = []; - const server = http.createServer((req, res) => { - let data = ""; - req.on("data", (chunk) => (data += chunk)); - req.on("end", () => { - const route = - Object.entries(routes).find(([suffix]) => req.url?.endsWith(suffix))?.[1] ?? routes["*"]; - requests.push({ url: req.url, body: data ? JSON.parse(data) : null, at: Date.now() }); - const { delayMs = 0, status = 200, body = {} } = route ?? {}; - setTimeout(() => { - res.writeHead(status, { "content-type": "application/json" }); - res.end(JSON.stringify(body)); - }, delayMs); - }); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - const close = () => - new Promise((resolve) => { - server.closeAllConnections?.(); - server.close(resolve); - }); - openServers.push(close); - return { url: `http://127.0.0.1:${server.address().port}`, requests, close }; -} - -/** Parse GITHUB_OUTPUT heredoc format into a map (last write wins). */ -function parseOutputs(text) { - const outputs = {}; - const re = /^(.+?)<<(\S+)\n([\s\S]*?)\n\2$/gm; - let m; - while ((m = re.exec(text)) !== null) outputs[m[1]] = m[3]; - return outputs; -} - -/** Run the action with the given inputs; returns exit code, logs, outputs, summary. */ -async function runAction({ inputs = {}, policy = null, timeoutMs = 15000 }) { - const dir = await mkdtemp(join(tmpdir(), "govern-int-")); - const outputFile = join(dir, "outputs.txt"); - const summaryFile = join(dir, "summary.md"); - await writeFile(outputFile, ""); - await writeFile(summaryFile, ""); - if (policy) await writeFile(join(dir, "DECIONIS_POLICY.md"), policy); - - const base = Object.fromEntries( - Object.entries(process.env).filter( - ([k]) => !k.startsWith("GITHUB_") && !k.startsWith("INPUT_"), - ), - ); - const env = { - ...base, - GITHUB_OUTPUT: outputFile, - GITHUB_STEP_SUMMARY: summaryFile, - GITHUB_WORKSPACE: dir, - ...Object.fromEntries( - Object.entries(inputs).map(([k, v]) => [ - `INPUT_${k.toUpperCase().replace(/-/g, "_")}`, - String(v), - ]), - ), - }; - - const startedAt = Date.now(); - const child = spawn(process.execPath, [INDEX], { env, stdio: ["ignore", "pipe", "pipe"] }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (d) => (stdout += d)); - child.stderr.on("data", (d) => (stderr += d)); - const code = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - child.kill("SIGKILL"); - reject(new Error(`action did not exit within ${timeoutMs}ms\nstdout:\n${stdout}`)); - }, timeoutMs); - child.on("close", (c) => { - clearTimeout(timer); - resolve(c ?? 0); - }); - }); - return { - code, - stdout, - stderr, - durationMs: Date.now() - startedAt, - outputs: parseOutputs(await readFile(outputFile, "utf8")), - summary: await readFile(summaryFile, "utf8"), - }; -} - -// GitHub injects action.yml defaults as INPUT_* env vars; a bare spawn does -// not, so tests that rely on the default policy path pass it explicitly. -const CREDS = { - "api-key": "test-key", - "org-id": "org-int", - "workflow-key": "wf-int", - "policy-file": "DECIONIS_POLICY.md", -}; -const MARK = 'node -e "console.log(\'CMD_TS_\' + Date.now())"'; -const markTime = (stdout) => { - const m = /CMD_TS_(\d+)/.exec(stdout); - return m ? Number(m[1]) : null; -}; - -const POLICY_BLOCK = `# Policy\n\n\`\`\`decionis\n{"version":1,"rules":[{"name":"Block gated","all":[{"field":"context.gate","op":"eq","value":true}],"action":"block","domain":"*"}]}\n\`\`\`\n`; -const POLICY_ALLOW = POLICY_BLOCK.replace('"action":"block"', '"action":"allow"').replace( - "Block gated", - "Allow gated", -); - -describe("integration: speculative shadow (branch A)", () => { - it("(a) starts the command before the delayed API responds, exits 0, records the verdict", async () => { - const api = await startMockApi({ "*": { delayMs: 3000, body: ALLOW_RESPONSE } }); - const t0 = Date.now(); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "shadow", - run: MARK, - "api-base-url": api.url, - "request-timeout-ms": "8000", - }, - }); - assert.equal(r.code, 0); - const cmdTs = markTime(r.stdout); - assert.ok(cmdTs, "command marker printed"); - assert.ok(cmdTs - t0 < 2500, `command started fast (took ${cmdTs - t0}ms)`); - assert.ok(r.durationMs >= 2900, "step waited for the background verdict in grace"); - assert.equal(r.outputs.decision, "allow"); // APPROVE normalized - assert.equal(r.outputs["decision-source"], "api"); - assert.equal(r.outputs.executed, "true"); - assert.match(r.outputs["verify-url"], /dsr-int/); - assert.match(r.stdout, /Decionis timing β€”/); - assert.equal((r.summary.match(/Decionis Action Gate/g) ?? []).length, 1, "summary written once"); - await api.close(); - }); - - it("(b) API unreachable: step exits with the command's code and only notices", async () => { - const r = await runAction({ - inputs: { - ...CREDS, - mode: "shadow", - run: 'node -e "process.exit(7)"', - "api-base-url": "http://127.0.0.1:9", - "request-timeout-ms": "2000", - }, - }); - assert.equal(r.code, 7); - assert.match(r.stdout, /::notice::Decionis \(non-fatal\) shadow evaluation/); - assert.doesNotMatch(r.stdout, /::error::/); - assert.equal(r.outputs.decision, ""); - }); - - it("(c) shadow exit code is exactly the command's, even on success paths", async () => { - const api = await startMockApi({ "*": { body: ALLOW_RESPONSE } }); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "shadow", - run: 'node -e "process.exit(3)"', - "api-base-url": api.url, - "request-timeout-ms": "3000", - }, - }); - assert.equal(r.code, 3); - assert.equal(r.outputs.decision, "allow"); - await api.close(); - }); - - it("(k) missing credentials in shadow: command still runs, step never fails", async () => { - const r = await runAction({ - inputs: { mode: "shadow", run: 'node -e "process.exit(5)"' }, - }); - assert.equal(r.code, 5); - assert.match(r.stdout, /not configured yet/); - assert.doesNotMatch(r.stdout, /::error::/); - }); -}); - -describe("integration: local verdict short-circuit (branches C/D)", () => { - it("(d) local block fails fast without waiting for a slow API, and records bounded", async () => { - const api = await startMockApi({ "*": { delayMs: 5000, body: ALLOW_RESPONSE } }); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "enforce", - run: MARK, - payload: '{"gate":true}', - "api-base-url": api.url, - "request-timeout-ms": "1500", - }, - policy: POLICY_BLOCK, - }); - assert.equal(r.code, 1); - assert.ok(r.durationMs < 4000, `short-circuited (took ${r.durationMs}ms vs 5s API)`); - assert.equal(markTime(r.stdout), null, "gated command never ran"); - assert.equal(r.outputs.executed, "false"); - assert.equal(r.outputs.decision, "block"); - assert.equal(r.outputs["decision-source"], "local"); - assert.match(r.stdout, /BLOCKED execution locally \(rule "Block gated"/); - assert.match(r.stdout, /⚑ Decionis local verdict 'block'/); - assert.equal(api.requests.length, 1, "bounded dossier-recording call was attempted"); - await api.close(); - }); - - it("(e) local allow runs the command while notarizing; API disagreement flags a mismatch", async () => { - const api = await startMockApi({ - "*": { delayMs: 300, body: { ...ALLOW_RESPONSE, outcome: "REJECT" } }, - }); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "enforce", - run: 'node -e "setTimeout(() => {}, 800)"', - payload: '{"gate":true}', - "api-base-url": api.url, - "request-timeout-ms": "5000", - }, - policy: POLICY_ALLOW, - }); - assert.equal(r.code, 0); - assert.equal(r.outputs.executed, "true"); - assert.equal(r.outputs.decision, "allow", "the acted local verdict is reported"); - assert.equal(r.outputs["decision-source"], "local"); - assert.equal(r.outputs["verdict-mismatch"], "true"); - assert.equal(r.outputs["dossier-id"], "dsr-int", "API dossier still recorded"); - assert.match(r.stdout, /::warning::Decionis verdict mismatch/); - await api.close(); - }); - - it("(f) strict mode: deterministic local allow makes zero network calls", async () => { - const api = await startMockApi({ "*": { body: ALLOW_RESPONSE } }); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "enforce", - run: MARK, - payload: '{"gate":true}', - "local-eval": "strict", - "api-base-url": api.url, - }, - policy: POLICY_ALLOW, - }); - assert.equal(r.code, 0); - assert.ok(markTime(r.stdout), "command ran"); - assert.equal(api.requests.length, 0, "no API request was made"); - assert.equal(r.outputs.decision, "allow"); - assert.equal(r.outputs["decision-source"], "local"); - assert.match(r.stdout, /API roundtrip skipped \(local-eval: strict\)/); - await api.close(); - }); -}); - -describe("integration: legacy blocking path (branch E)", () => { - it("(g) no policy file: an API block fails the advisory step exactly like v1.8", async () => { - const api = await startMockApi({ "*": { body: { ...ALLOW_RESPONSE, outcome: "REJECT" } } }); - const r = await runAction({ - inputs: { ...CREDS, mode: "enforce", "api-base-url": api.url }, - }); - assert.equal(r.code, 1); - assert.equal(r.outputs.decision, "block"); - assert.equal(r.outputs["decision-source"], "api"); - assert.match(r.stdout, /::error::Decionis blocked this step/); - await api.close(); - }); - - it("(h) enforce fails closed on an API 500", async () => { - const api = await startMockApi({ "*": { status: 500, body: { error: "boom" } } }); - const r = await runAction({ - inputs: { ...CREDS, mode: "enforce", "api-base-url": api.url }, - }); - assert.equal(r.code, 1); - assert.match(r.stdout, /::error::Decionis API returned 500/); - await api.close(); - }); - - it("(i) shadow verdict-only swallows an API 500 and exits 0", async () => { - const api = await startMockApi({ "*": { status: 500, body: { error: "boom" } } }); - const r = await runAction({ - inputs: { ...CREDS, mode: "shadow", "api-base-url": api.url }, - }); - assert.equal(r.code, 0); - assert.match(r.stdout, /::notice::Decionis \(non-fatal\) shadow evaluation/); - assert.doesNotMatch(r.stdout, /::error::/); - assert.ok(r.summary.includes("Decionis Action Gate"), "summary still written"); - await api.close(); - }); - - it("(j) request-grant disables speculation: verdict, then grant, then the command", async () => { - const api = await startMockApi({ - "/v1/protocol/evaluate-decision": { delayMs: 400, body: ALLOW_RESPONSE }, - "/v1/protocol/execution-grants/issue": { - body: { execution_grant: "grant-jwt", expires_at: "2027-01-01T00:00:00Z" }, - }, - }); - const r = await runAction({ - inputs: { - ...CREDS, - mode: "shadow", - run: 'node -e "console.log(\'CMD_TS_\' + Date.now() + \'_GRANT=\' + (process.env.DECIONIS_EXECUTION_GRANT || \'none\'))"', - "request-grant": "true", - "api-base-url": api.url, - "request-timeout-ms": "5000", - }, - }); - assert.equal(r.code, 0); - const cmdTs = markTime(r.stdout); - assert.ok(cmdTs, "command ran"); - assert.ok( - cmdTs >= api.requests[0].at, - "the evaluate call completed before the command started (no speculation with grants)", - ); - assert.match(r.stdout, /_GRANT=grant-jwt/); - assert.equal(r.outputs["execution-grant"], "grant-jwt"); - await api.close(); - }); -}); diff --git a/test/orchestration.test.mjs b/test/orchestration.test.mjs deleted file mode 100644 index 6db7b97..0000000 --- a/test/orchestration.test.mjs +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; - -import { - DEFAULT_LOCAL_EVAL, - SHADOW_GRACE_CAP_MS, - SHADOW_GRACE_EXTRA_MS, - BLOCK_RECORD_CAP_MS, - planEvaluation, - computeGraceMs, - createTimeline, - buildVerifyUrl, - shouldFail, -} from "../src/index.mjs"; -import { normalizeVerdict, parseDecisionResponse } from "../src/api-client.mjs"; - -const localAllow = { deterministic: true, outcome: "allow" }; -const localBlock = { deterministic: true, outcome: "block" }; -const localFallback = { deterministic: false, outcome: null }; - -describe("planEvaluation (the local-eval decision table)", () => { - it("defaults to auto", () => { - assert.equal(DEFAULT_LOCAL_EVAL, "auto"); - }); - - it("routes to the blocking API without a deterministic local verdict", () => { - for (const localEval of ["auto", "strict", "off"]) { - for (const localOutcome of [null, localFallback]) { - assert.deepEqual( - planEvaluation({ localOutcome, localEval, requestGrant: false, hasRunCommand: true }), - { act: "api", api: "blocking" }, - ); - } - } - }); - - it("local-eval off ignores even a deterministic verdict", () => { - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "off", requestGrant: false, hasRunCommand: true }), - { act: "api", api: "blocking" }, - ); - }); - - it("request-grant always forces the blocking path (grants need a dossier first)", () => { - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "auto", requestGrant: true, hasRunCommand: true }), - { act: "api", api: "blocking" }, - ); - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "strict", requestGrant: true, hasRunCommand: false }), - { act: "api", api: "blocking" }, - ); - }); - - it("strict: deterministic verdicts act locally with zero network", () => { - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "strict", requestGrant: false, hasRunCommand: true }), - { act: "local", api: "none" }, - ); - assert.deepEqual( - planEvaluation({ localOutcome: localBlock, localEval: "strict", requestGrant: false, hasRunCommand: false }), - { act: "local", api: "none" }, - ); - }); - - it("auto: local block records the dossier with a bounded wait", () => { - assert.deepEqual( - planEvaluation({ localOutcome: localBlock, localEval: "auto", requestGrant: false, hasRunCommand: true }), - { act: "local", api: "bounded" }, - ); - }); - - it("auto: local allow notarizes in the background while a command runs, bounded otherwise", () => { - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "auto", requestGrant: false, hasRunCommand: true }), - { act: "local", api: "background" }, - ); - assert.deepEqual( - planEvaluation({ localOutcome: localAllow, localEval: "auto", requestGrant: false, hasRunCommand: false }), - { act: "local", api: "bounded" }, - ); - }); -}); - -describe("computeGraceMs", () => { - it("grants the remaining timeout budget plus the parsing allowance", () => { - // Pipeline started 1s ago with a 5s timeout β†’ 4s remaining + extra. - assert.equal(computeGraceMs(0, 1000, 5000), 4000 + SHADOW_GRACE_EXTRA_MS); - }); - - it("floors at the extra allowance when the budget is exhausted", () => { - assert.equal(computeGraceMs(0, 60_000, 5000), SHADOW_GRACE_EXTRA_MS); - }); - - it("caps long budgets so fast commands never wait long", () => { - assert.equal(computeGraceMs(0, 0, 120_000), SHADOW_GRACE_CAP_MS); - assert.ok(SHADOW_GRACE_CAP_MS <= 10_000); - assert.ok(BLOCK_RECORD_CAP_MS <= SHADOW_GRACE_CAP_MS); - }); -}); - -describe("createTimeline", () => { - it("renders marks with offsets and details", () => { - const t = createTimeline(0); - t.mark("local eval", "'allow' in 412Β΅s"); - t.mark("command started"); - const rendered = t.render(); - assert.match(rendered, /^Decionis timing β€” /); - assert.match(rendered, /local eval 'allow' in 412Β΅s \+\d+(\.\d+)?m?s/); - assert.match(rendered, /command started \+/); - assert.equal(t.events.length, 2); - }); - - it("says so when nothing was recorded", () => { - assert.match(createTimeline().render(), /no events recorded/); - }); -}); - -describe("buildVerifyUrl policy pinning", () => { - it("appends the policy revision pin as an additive query param", () => { - const url = buildVerifyUrl("https://decionis.com", "dsr-1", "sig-1", { - policySha256: "abc123", - }); - assert.equal( - url, - "https://decionis.com/verify/decision-dossiers/dsr-1?sig=sig-1&source=github_actions&policy=sha256%3Aabc123", - ); - }); - - it("pins without a signature too", () => { - const url = buildVerifyUrl("https://decionis.com", "dsr-1", null, { policySha256: "abc" }); - assert.equal( - url, - "https://decionis.com/verify/decision-dossiers/dsr-1?source=github_actions&policy=sha256%3Aabc", - ); - }); - - it("three-argument calls stay byte-identical to v1.8", () => { - assert.equal( - buildVerifyUrl("https://decionis.com", "dsr-abc", "sha256:0123"), - "https://decionis.com/verify/decision-dossiers/dsr-abc?sig=sha256%3A0123&source=github_actions", - ); - assert.equal( - buildVerifyUrl("https://decionis.com", "dsr-abc", null), - "https://decionis.com/verify/decision-dossiers/dsr-abc?source=github_actions", - ); - }); -}); - -describe("verdict normalization (protocol outcome β†’ gate vocabulary)", () => { - it("maps the protocol outcomes onto allow/block/review", () => { - assert.equal(normalizeVerdict("APPROVE"), "allow"); - assert.equal(normalizeVerdict("REJECT"), "block"); - assert.equal(normalizeVerdict("REVIEW"), "review"); - assert.equal(normalizeVerdict("ESCALATE"), "escalate"); - assert.equal(normalizeVerdict("AUTO_APPROVE"), "allow"); - assert.equal(normalizeVerdict("AUTO_REJECT"), "block"); - assert.equal(normalizeVerdict("REQUIRE_REVIEW"), "review"); - }); - - it("passes through the legacy vocabulary untouched", () => { - for (const v of ["allow", "block", "deny", "denied", "escalate", "review", "restrain"]) { - assert.equal(normalizeVerdict(v), v); - } - }); - - it("parseDecisionResponse normalizes outcome and keeps the wire value", () => { - const parsed = parseDecisionResponse({ - outcome: "APPROVE", - dossier_id: "dsr-9", - reason_codes: ["ok"], - }); - assert.equal(parsed.decision, "allow"); - assert.equal(parsed.rawDecision, "approve"); - assert.equal(parsed.dossierId, "dsr-9"); - assert.equal(parsed.reasonCode, "ok"); - }); - - it("parseDecisionResponse falls back through reason_code and tolerates junk", () => { - assert.equal(parseDecisionResponse({ reason_code: "rc" }).reasonCode, "rc"); - assert.equal(parseDecisionResponse({ reason_codes: "nope", reason_code: "rc" }).reasonCode, "rc"); - assert.equal(parseDecisionResponse({}).reasonCode, null); - }); -}); - -describe("local verdicts obey the same gates as API verdicts", () => { - it("a local block with fail-on: never or escalate does not fail the step", () => { - assert.equal(shouldFail("block", "never", "enforce"), false); - assert.equal(shouldFail("block", "escalate", "enforce"), false); - assert.equal(shouldFail("block", "block", "enforce"), true); - }); - - it("the normalized API 'review' verdict lands in the escalate family", () => { - assert.equal(shouldFail("review", "escalate", "enforce"), true); - assert.equal(shouldFail("review", "block", "enforce"), false); - }); -}); diff --git a/test/policy-engine.test.mjs b/test/policy-engine.test.mjs deleted file mode 100644 index e515158..0000000 --- a/test/policy-engine.test.mjs +++ /dev/null @@ -1,554 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - -import { - SUPPORTED_OPS, - UNKNOWN_FACT, - extractDecionisBlock, - parsePolicy, - buildEvaluationFacts, - resolveFactValue, - valuesEqual, - evaluatePredicate, - evaluateRuleConditions, - isDomainCertain, - evaluatePolicy, - evaluateLocalPolicy, -} from "../src/policy-engine.mjs"; - -const examplePath = (name) => fileURLToPath(new URL(`../examples/${name}`, import.meta.url)); - -/** Wrap a rules document in a policy markdown file. */ -const md = (rulesJson, info = "decionis") => - `# Policy\n\nProse here.\n\n\`\`\`${info}\n${rulesJson}\n\`\`\`\n`; - -const RULES = (rules) => JSON.stringify({ version: 1, rules }); - -const ALLOW_STAGING = { - name: "Allow staging", - all: [{ field: "context.environment", op: "eq", value: "staging" }], - action: "allow", - domain: "*", -}; -const BLOCK_FREEZE = { - name: "Block during freeze", - all: [{ field: "context.change_freeze", op: "eq", value: true }], - action: "block", - domain: "*", -}; - -describe("extractDecionisBlock (mirrors the platform compiler regex)", () => { - it("extracts the first block's trimmed body", () => { - const body = extractDecionisBlock(md('{"version":1,"rules":[]}')); - assert.equal(body, '{"version":1,"rules":[]}'); - }); - - it("returns null when there is no block, or input is not a string", () => { - assert.equal(extractDecionisBlock("# just prose"), null); - assert.equal(extractDecionisBlock(undefined), null); - assert.equal(extractDecionisBlock(null), null); - }); - - it("returns null for an empty block body", () => { - assert.equal(extractDecionisBlock("```decionis\n \n```"), null); - }); - - it("takes the FIRST of multiple blocks, like the server", () => { - const text = md('{"first":1}') + "\n" + md('{"second":2}'); - assert.equal(extractDecionisBlock(text), '{"first":1}'); - }); - - it("accepts an optional json/yaml info tag and CRLF line endings", () => { - assert.equal(extractDecionisBlock("```decionis json\r\n{\"a\":1}\r\n```"), '{"a":1}'); - assert.equal(extractDecionisBlock("```decionis yaml\n{\"a\":1}\n```"), '{"a":1}'); - assert.equal(extractDecionisBlock("```DECIONIS\n{\"a\":1}\n```"), '{"a":1}'); - }); - - it("returns null for an unterminated fence", () => { - assert.equal(extractDecionisBlock("```decionis\n{\"a\":1}\n"), null); - }); -}); - -describe("parsePolicy (mirrors compileDecionisRules)", () => { - it("compiles both shipped example policies cleanly (lockstep with examples/)", async () => { - for (const name of ["DECIONIS_POLICY.md", "DECIONIS_POLICY.devops.md"]) { - const content = await readFile(examplePath(name), "utf8"); - const block = extractDecionisBlock(content); - assert.ok(block, `${name} has a decionis block`); - const compiled = parsePolicy(block, { workflowKey: "github_deploy_approval" }); - assert.equal(compiled.ok, true, `${name} compiles: ${JSON.stringify(compiled)}`); - assert.ok(compiled.rules.length >= 3, `${name} has rules`); - } - }); - - it("reports not_json for YAML-style bodies (server would still parse them)", () => { - const r = parsePolicy("rules:\n - name: x\n"); - assert.deepEqual(r, { ok: false, reason: "not_json" }); - }); - - it("rejects non-object documents and missing/empty rules", () => { - assert.equal(parsePolicy("[1,2]").ok, false); - assert.equal(parsePolicy('{"version":1}').ok, false); - assert.equal(parsePolicy('{"version":1,"rules":[]}').ok, false); - }); - - it("is all-or-nothing: one bad rule rejects the whole block", () => { - const r = parsePolicy( - RULES([ALLOW_STAGING, { name: "bad", all: [{ field: "x", op: "regex", value: "y" }], action: "allow" }]), - ); - assert.equal(r.ok, false); - assert.equal(r.reason, "compile_error"); - assert.match(r.errors[0], /unknown operator "regex"/); - }); - - it("requires exactly one non-empty all/any (empty arrays count as absent)", () => { - const both = parsePolicy( - RULES([{ name: "x", all: [{ field: "a", op: "eq", value: 1 }], any: [{ field: "b", op: "eq", value: 2 }], action: "allow" }]), - ); - assert.equal(both.ok, false); - const neither = parsePolicy(RULES([{ name: "x", action: "allow" }])); - assert.equal(neither.ok, false); - // all: [] is treated as absent, so a populated any is fine β€” mirror quirk. - const emptyAll = parsePolicy( - RULES([{ name: "x", all: [], any: [{ field: "b", op: "eq", value: 2 }], action: "allow", domain: "*" }]), - ); - assert.equal(emptyAll.ok, true); - assert.equal(emptyAll.rules[0].quantifier, "any"); - }); - - it("validates action, name, and per-op value requirements", () => { - assert.equal(parsePolicy(RULES([{ name: "x", all: [{ field: "a", op: "eq", value: 1 }], action: "veto" }])).ok, false); - assert.equal(parsePolicy(RULES([{ name: " ", all: [{ field: "a", op: "eq", value: 1 }], action: "allow" }])).ok, false); - assert.equal(parsePolicy(RULES([{ name: "x", all: [{ field: "a", op: "eq" }], action: "allow" }])).ok, false); - assert.equal(parsePolicy(RULES([{ name: "x", all: [{ field: "a", op: "in", value: [1] }], action: "allow" }])).ok, false); - const okIn = parsePolicy(RULES([{ name: "x", all: [{ field: "a", op: "in", values: [1, 2] }], action: "allow" }])); - assert.equal(okIn.ok, true); - assert.deepEqual(okIn.rules[0].predicates[0].values, [1, 2]); - }); - - it("drops any provided value for exists (pure existence, like the compiler)", () => { - const r = parsePolicy(RULES([{ name: "x", all: [{ field: "a", op: "exists", value: false }], action: "allow" }])); - assert.equal(r.ok, true); - const predicate = r.rules[0].predicates[0]; - assert.equal(predicate.operator, "EXISTS"); - assert.equal("value" in predicate, false); - }); - - it("ignores unknown keys (top-level and per-rule) and the version field", () => { - const r = parsePolicy( - JSON.stringify({ - version: 99, - comment: "ignored", - rules: [{ ...ALLOW_STAGING, severity: "high", notes: "ignored" }], - }), - ); - assert.equal(r.ok, true); - }); - - it("honors priority (clamped, rounded) and falls back to the rule index", () => { - const r = parsePolicy( - RULES([ - { ...ALLOW_STAGING, priority: 2.7 }, - { ...BLOCK_FREEZE, priority: "5" }, - { ...BLOCK_FREEZE, name: "third", priority: 999999 }, - ]), - ); - assert.equal(r.ok, true); - assert.equal(r.rules[0].priority, 3); - assert.equal(r.rules[1].priority, 1); // non-number priority β†’ index - assert.equal(r.rules[2].priority, 100000); // clamped - }); - - it("defaults rule domain to the workflow key, then github_action", () => { - const rule = { name: "x", all: [{ field: "a", op: "eq", value: 1 }], action: "allow" }; - assert.equal(parsePolicy(RULES([rule]), { workflowKey: "deploy_gate" }).rules[0].domain, "deploy_gate"); - assert.equal(parsePolicy(RULES([rule])).rules[0].domain, "github_action"); - assert.equal(parsePolicy(RULES([{ ...rule, domain: " * " }])).rules[0].domain, "*"); - }); - - it("keeps op aliases: ne and neq both compile to NEQ", () => { - const r = parsePolicy( - RULES([ - { name: "a", all: [{ field: "x", op: "ne", value: 1 }], action: "allow" }, - { name: "b", all: [{ field: "x", op: "NEQ", value: 1 }], action: "allow" }, - ]), - ); - assert.equal(r.ok, true); - assert.equal(r.rules[0].predicates[0].operator, "NEQ"); - assert.equal(r.rules[1].predicates[0].operator, "NEQ"); - assert.equal(SUPPORTED_OPS.includes("neq"), true); - }); -}); - -describe("buildEvaluationFacts (mirrors normalize + handler + facts builder)", () => { - const facts = buildEvaluationFacts( - { action: "production-deploy", environment: "prod", amount: 5000, signals: { velocity: 3 } }, - { orgId: "org-1", workflowKey: "deploy_gate" }, - ); - - it("merges the payload into context and injects workflow_key", () => { - assert.equal(facts.context.environment, "prod"); - assert.equal(facts.context.workflow_key, "deploy_gate"); - assert.equal(facts.intake, facts.context); - assert.equal(facts.signals.velocity, 3); - }); - - it("derives decision_type from payload.action, then workflow_key, then action_gate", () => { - assert.equal(facts.decision_type, "production-deploy"); - assert.equal( - buildEvaluationFacts({}, { workflowKey: "deploy_gate" }).decision_type, - "deploy_gate", - ); - assert.equal(buildEvaluationFacts({}, {}).decision_type, "action_gate"); - const long = "x".repeat(300); - assert.equal(buildEvaluationFacts({ action: long }, {}).decision_type.length, 120); - }); - - it("masks request-level fields at the root exactly like the server quirk", () => { - // payload.amount is visible via context.amount but the root `amount` is the - // (absent) request field β€” the explicit key overwrites the context spread. - assert.equal(facts.amount, undefined); - assert.equal(Object.prototype.hasOwnProperty.call(facts, "amount"), true); - assert.equal(facts.context.amount, 5000); - }); - - it("marks server-only facts as UNKNOWN_FACT", () => { - assert.equal(facts.decision_domain, UNKNOWN_FACT); - assert.equal(facts.mode, UNKNOWN_FACT); - assert.equal(facts.policy, UNKNOWN_FACT); - assert.equal(facts.context.vertical_pack, UNKNOWN_FACT); - }); - - it("collects attachments from context or evidence, with a count", () => { - const a = buildEvaluationFacts({ attachments: [1, 2] }, {}); - assert.equal(a.attachment_count, 2); - const b = buildEvaluationFacts({ evidence: { attachments: [1] } }, {}); - assert.equal(b.attachment_count, 1); - assert.equal(buildEvaluationFacts({}, {}).attachment_count, 0); - }); -}); - -describe("resolveFactValue (mirrors resolveEvaluateDecisionPolicyFactValue)", () => { - const facts = buildEvaluationFacts( - { "dotted.literal": "root-hit", nested: { deep: { value: 7 } }, list: ["a", "b"] }, - { orgId: "org-1", workflowKey: "wf" }, - ); - - it("prefers an exact root key, even one containing dots", () => { - assert.deepEqual(resolveFactValue(facts, "dotted.literal"), { kind: "value", value: "root-hit" }); - }); - - it("resolves dot paths and bracket indices", () => { - assert.deepEqual(resolveFactValue(facts, "context.nested.deep.value"), { kind: "value", value: 7 }); - assert.deepEqual(resolveFactValue(facts, "nested.deep.value"), { kind: "value", value: 7 }); - assert.deepEqual(resolveFactValue(facts, "list[1]"), { kind: "value", value: "b" }); - assert.deepEqual(resolveFactValue(facts, "context.list.0"), { kind: "value", value: "a" }); - }); - - it("resolves missing paths to undefined (not unknown)", () => { - assert.deepEqual(resolveFactValue(facts, "context.absent.leaf"), { kind: "value", value: undefined }); - assert.deepEqual(resolveFactValue(facts, "list.notanumber"), { kind: "value", value: undefined }); - }); - - it("reports unknown when traversal touches a server-only fact", () => { - assert.equal(resolveFactValue(facts, "decision_domain").kind, "unknown"); - assert.equal(resolveFactValue(facts, "policy.bundle_id").kind, "unknown"); - assert.equal(resolveFactValue(facts, "context.vertical_pack").kind, "unknown"); - assert.equal(resolveFactValue(facts, "mode").kind, "unknown"); - }); -}); - -describe("valuesEqual (mirrors areEvaluateDecisionPolicyValuesEqual)", () => { - it("coerces numbers, booleans, and case-insensitive text", () => { - assert.equal(valuesEqual("1", 1), true); - assert.equal(valuesEqual(1, true), true); - assert.equal(valuesEqual("true", true), true); - assert.equal(valuesEqual("Production", "production"), true); - assert.equal(valuesEqual(" padded ", "padded"), true); - assert.equal(valuesEqual("12abc", 12), true); // parseFloat prefix quirk β€” server mirror - assert.equal(valuesEqual("1", true), false); // number stage skipped, boolean("1")=null, text differs - }); - - it("matches any element when either side is an array", () => { - assert.equal(valuesEqual(["a", "b"], "B"), true); - assert.equal(valuesEqual("a", ["x", "A"]), true); - assert.equal(valuesEqual(["a"], ["b", ["a"]]), true); - }); - - it("falls back to strict identity", () => { - assert.equal(valuesEqual(null, null), true); - assert.equal(valuesEqual(undefined, null), false); - assert.equal(valuesEqual({ a: 1 }, { a: 1 }), false); - }); -}); - -describe("evaluatePredicate (server-exact operators, three-valued only for unknowns)", () => { - const facts = buildEvaluationFacts( - { - action: "production-deploy", - environment: "Production", - blast_radius: "15000", - change_freeze: true, - branch: "release/1.2", - labels: ["infra", "ai-authored"], - }, - { orgId: "org-1", workflowKey: "deploy_gate" }, - ); - const p = (field, op, rest = {}) => { - const parsed = parsePolicy( - RULES([{ name: "t", all: [{ field, op, ...rest }], action: "allow" }]), - ); - assert.equal(parsed.ok, true, JSON.stringify(parsed)); - return parsed.rules[0].predicates[0]; - }; - - it("eq / neq with coercion", () => { - assert.equal(evaluatePredicate(p("context.environment", "eq", { value: "production" }), facts), true); - assert.equal(evaluatePredicate(p("decision_type", "eq", { value: "production-deploy" }), facts), true); - assert.equal(evaluatePredicate(p("context.change_freeze", "eq", { value: "true" }), facts), true); - assert.equal(evaluatePredicate(p("context.environment", "neq", { value: "staging" }), facts), true); - assert.equal(evaluatePredicate(p("context.missing", "eq", { value: "x" }), facts), false); - }); - - it("numeric comparisons coerce strings and fail closed on non-numbers", () => { - assert.equal(evaluatePredicate(p("context.blast_radius", "gt", { value: 10000 }), facts), true); - assert.equal(evaluatePredicate(p("context.blast_radius", "lte", { value: "15000" }), facts), true); - assert.equal(evaluatePredicate(p("context.environment", "gt", { value: 1 }), facts), false); - assert.equal(evaluatePredicate(p("context.missing", "gt", { value: 1 }), facts), false); - }); - - it("in / not_in use the values array with semantic equality", () => { - assert.equal(evaluatePredicate(p("context.environment", "in", { values: ["staging", "PRODUCTION"] }), facts), true); - assert.equal(evaluatePredicate(p("context.environment", "not_in", { values: ["staging"] }), facts), true); - assert.equal(evaluatePredicate(p("context.environment", "not_in", { values: ["production"] }), facts), false); - }); - - it("exists is pure existence; null does not exist", () => { - assert.equal(evaluatePredicate(p("context.change_freeze", "exists"), facts), true); - assert.equal(evaluatePredicate(p("context.missing", "exists"), facts), false); - const withNull = buildEvaluationFacts({ maybe: null }, {}); - assert.equal(evaluatePredicate(p("context.maybe", "exists"), withNull), false); - }); - - it("contains: any-element for arrays, case-insensitive substring for text", () => { - assert.equal(evaluatePredicate(p("context.labels", "contains", { value: "INFRA" }), facts), true); - assert.equal(evaluatePredicate(p("context.branch", "contains", { value: "RELEASE" }), facts), true); - assert.equal(evaluatePredicate(p("context.branch", "contains", { value: "hotfix" }), facts), false); - assert.equal(evaluatePredicate(p("context.change_freeze", "contains", { value: "tru" }), facts), true); - }); - - it("matches: case-insensitive regex; invalid patterns are false", () => { - assert.equal(evaluatePredicate(p("context.branch", "matches", { value: "^release/" }), facts), true); - assert.equal(evaluatePredicate(p("context.branch", "matches", { value: "(" }), facts), false); - assert.equal(evaluatePredicate(p("context.missing", "matches", { value: "x" }), facts), false); - }); - - it("returns unknown only when the fact is server-only", () => { - assert.equal(evaluatePredicate(p("decision_domain", "eq", { value: "DEVOPS" }), facts), "unknown"); - assert.equal(evaluatePredicate(p("policy.version", "exists"), facts), "unknown"); - }); -}); - -describe("evaluateRuleConditions (Kleene logic)", () => { - const facts = buildEvaluationFacts({ a: 1 }, { orgId: "o", workflowKey: "wf" }); - const rule = (quantifier, conditions) => { - const parsed = parsePolicy(RULES([{ name: "t", [quantifier]: conditions, action: "allow" }])); - assert.equal(parsed.ok, true); - return parsed.rules[0]; - }; - const T = { field: "context.a", op: "eq", value: 1 }; - const F = { field: "context.a", op: "eq", value: 2 }; - const U = { field: "decision_domain", op: "eq", value: "DEVOPS" }; - - it("all: false beats unknown; unknown beats true", () => { - assert.equal(evaluateRuleConditions(rule("all", [F, U]), facts), false); - assert.equal(evaluateRuleConditions(rule("all", [T, U]), facts), "unknown"); - assert.equal(evaluateRuleConditions(rule("all", [T, T]), facts), true); - }); - - it("any: true beats unknown; unknown beats false", () => { - assert.equal(evaluateRuleConditions(rule("any", [T, U]), facts), true); - assert.equal(evaluateRuleConditions(rule("any", [F, U]), facts), "unknown"); - assert.equal(evaluateRuleConditions(rule("any", [F, F]), facts), false); - }); -}); - -describe("evaluatePolicy (ordering, domain certainty, halt rule)", () => { - const facts = buildEvaluationFacts( - { environment: "staging", change_freeze: true }, - { orgId: "o", workflowKey: "wf" }, - ); - const compile = (rules) => { - const parsed = parsePolicy(RULES(rules), { workflowKey: "wf" }); - assert.equal(parsed.ok, true, JSON.stringify(parsed)); - return parsed.rules; - }; - - it("default priorities mirror the server quirk: the LATER rule sorts first", () => { - // The platform compiler defaults priority = rule index and the evaluator - // sorts priority DESC, so with no explicit priorities the last rule in - // the file is evaluated first. This engine mirrors that reality so local - // verdicts predict server verdicts (the ordering fix belongs upstream). - const r = evaluatePolicy(compile([BLOCK_FREEZE, ALLOW_STAGING]), facts); - assert.equal(r.outcome, "allow"); - assert.equal(r.matchedRule.name, "Allow staging"); - }); - - it("equal explicit priorities restore file order via the index tiebreak", () => { - const r = evaluatePolicy( - compile([ - { ...BLOCK_FREEZE, priority: 10 }, - { ...ALLOW_STAGING, priority: 10 }, - ]), - facts, - ); - assert.equal(r.outcome, "block"); - assert.equal(r.matchedRule.name, "Block during freeze"); - }); - - it("priority reorders ahead of file order; ties break by index", () => { - const r = evaluatePolicy( - compile([BLOCK_FREEZE, { ...ALLOW_STAGING, priority: 50 }]), - facts, - ); - assert.equal(r.outcome, "allow"); - }); - - it("non-matching rules are skipped even with uncertain domains", () => { - const noMatchUncertainDomain = { - name: "not matching", - all: [{ field: "context.environment", op: "eq", value: "prod" }], - action: "block", // no domain β†’ defaults to workflow key β†’ uncertain, but inert - }; - const r = evaluatePolicy(compile([noMatchUncertainDomain, ALLOW_STAGING]), facts); - assert.equal(r.outcome, "allow"); - }); - - it("a matching rule with a non-wildcard domain is indeterminate", () => { - const domainBound = { ...BLOCK_FREEZE, domain: "DEVOPS" }; - const r = evaluatePolicy(compile([domainBound]), facts); - assert.equal(r.outcome, "indeterminate"); - assert.match(r.explanation, /domain "DEVOPS"/); - }); - - it("an unknown-condition rule halts the scan before later-sorted definitive rules", () => { - const unknownRule = { - name: "server-only", - all: [{ field: "decision_domain", op: "eq", value: "DEVOPS" }], - action: "block", - domain: "*", - priority: 100, // sorts first - }; - const r = evaluatePolicy(compile([unknownRule, ALLOW_STAGING]), facts); - assert.equal(r.outcome, "indeterminate"); - assert.equal(r.haltedRule.name, "server-only"); - }); - - it("a definitive match that sorts ahead of an unknown rule is not halted by it", () => { - const unknownRule = { - name: "server-only", - all: [{ field: "decision_domain", op: "eq", value: "DEVOPS" }], - action: "block", - domain: "*", // priority defaults to index 0 β€” sorts after the allow rule - }; - const r = evaluatePolicy(compile([unknownRule, { ...ALLOW_STAGING, priority: 100 }]), facts); - assert.equal(r.outcome, "allow"); - }); - - it("no matching rule β†’ no_match", () => { - const r = evaluatePolicy(compile([{ ...ALLOW_STAGING, all: [{ field: "context.environment", op: "eq", value: "prod" }] }]), facts); - assert.equal(r.outcome, "no_match"); - }); - - it("isDomainCertain accepts * and ANY only", () => { - assert.equal(isDomainCertain({ domain: "*" }), true); - assert.equal(isDomainCertain({ domain: " any " }), true); - assert.equal(isDomainCertain({ domain: "DEVOPS" }), false); - assert.equal(isDomainCertain({ domain: "github_action" }), false); - }); -}); - -describe("evaluateLocalPolicy (facade)", () => { - const opts = { payload: { environment: "staging" }, orgId: "org-1", workflowKey: "wf" }; - - it("falls back when there is no rules block or no policy text", () => { - assert.equal(evaluateLocalPolicy("# prose only", opts).fallbackReason, "no_rules_block"); - assert.equal(evaluateLocalPolicy("", opts).fallbackReason, "no_rules_block"); - }); - - it("falls back on YAML-ish or malformed blocks without judging them invalid", () => { - const yaml = evaluateLocalPolicy(md("rules:\n - name: x"), opts); - assert.equal(yaml.status, "fallback"); - assert.equal(yaml.fallbackReason, "not_json"); - const bad = evaluateLocalPolicy(md('{"rules":[{"name":"x","action":"allow"}]}'), opts); - assert.equal(bad.status, "fallback"); - assert.match(bad.fallbackReason, /^compile_error:/); - }); - - it("returns a deterministic allow/block for wildcard-domain matches", () => { - const allow = evaluateLocalPolicy(md(RULES([ALLOW_STAGING])), opts); - assert.equal(allow.status, "verdict"); - assert.equal(allow.outcome, "allow"); - assert.equal(allow.deterministic, true); - assert.equal(allow.matchedRule.name, "Allow staging"); - assert.ok(allow.elapsedUs >= 1); - assert.equal(allow.ruleCount, 1); - - const block = evaluateLocalPolicy(md(RULES([BLOCK_FREEZE])), { - ...opts, - payload: { change_freeze: true }, - }); - assert.equal(block.outcome, "block"); - assert.equal(block.deterministic, true); - }); - - it("reports matched escalate/restrain as non-deterministic predictions", () => { - const r = evaluateLocalPolicy( - md(RULES([{ ...BLOCK_FREEZE, name: "Escalate freeze", action: "escalate" }])), - { ...opts, payload: { change_freeze: true } }, - ); - assert.equal(r.status, "fallback"); - assert.equal(r.outcome, "escalate"); - assert.equal(r.deterministic, false); - assert.equal(r.fallbackReason, "outcome_not_local:escalate"); - assert.equal(r.matchedRule.name, "Escalate freeze"); - }); - - it("shipped example: an infra destroy matches the escalate rule and defers to the API", async () => { - const content = await readFile(examplePath("DECIONIS_POLICY.devops.md"), "utf8"); - const r = evaluateLocalPolicy(content, { - payload: { destroys_resources: true }, - orgId: "org-1", - workflowKey: "infra-destroy", - }); - assert.equal(r.status, "fallback"); - assert.equal(r.fallbackReason, "outcome_not_local:escalate"); - assert.equal(r.outcome, "escalate"); - assert.equal(r.matchedRule.name, "Escalate infrastructure destroy"); - }); - - it("shipped example: a change freeze yields a deterministic local block", async () => { - const content = await readFile(examplePath("DECIONIS_POLICY.devops.md"), "utf8"); - const r = evaluateLocalPolicy(content, { - payload: { change_freeze: true }, - orgId: "org-1", - workflowKey: "deploy", - }); - assert.equal(r.status, "verdict"); - assert.equal(r.outcome, "block"); - assert.equal(r.deterministic, true); - }); - - it("shipped example: a non-matching payload falls through to no_match", async () => { - const content = await readFile(examplePath("DECIONIS_POLICY.md"), "utf8"); - const r = evaluateLocalPolicy(content, { - payload: { environment: "dev" }, - orgId: "org-1", - workflowKey: "docs_only", - }); - assert.equal(r.status, "fallback"); - assert.equal(r.fallbackReason, "no_match"); - }); -});