@siax/agent-policy (0.1.0)
Installation
@siax:registry=https://git.cloud.siax.io/api/packages/sax3l/npm/npm install @siax/agent-policy@0.1.0"@siax/agent-policy": "0.1.0"About this package
@siax/agent-policy
Band30 lane P19 (agent sandbox / context security / rollout safety —
sax3l/siax-masterplan#38):
a fail-closed, default-deny policy contract for what an agent
identity/session is permitted to do, plus a real evaluator for it. Zero
EXTERNAL dependencies — @siax/schemas and @siax/write-guard are
workspace-internal links only. Node ESM (.mjs), Node ≥ 20 — same
convention as @siax/schemas, @siax/doctor and @siax/write-guard.
Why this package exists
This estate has spent a night dealing with an unidentified process that
auto-approves (via a siax-bot account) and then merges-or-closes pull
requests across many repos, and — a newer variant — pushes directly to
main, bypassing review entirely. Root cause, already confirmed and
tracked at masterplan#25:
every agent session in the estate authenticates with the same Gitea
site-admin credential, which can trivially Sudo-impersonate any other
account (including siax-bot) via Gitea's own built-in mechanism — and
that same credential is already piped into CI as an Actions secret. The
incident itself is tracked, still open and unattributed, at
masterplan#30.
Every pass through this estate tonight has operated under the same
manually-enforced rule: never self-approve, never self-merge, never
self-deploy. That rule lived entirely in prose and operator discipline.
This package is that rule, encoded as something a program can actually
check — packages/write-guard's sibling for this specific incident, the
same way authorize-repository-write.mjs was built directly from the P015
incident rather than as a generic "authz framework" that happens to fit.
What this is
-
A schema —
schema/siax.agent-capability-policy.schema.json(draft 2020-12, validated bypackages/schemas/src/siax.agent-capability-policy.mjs, documented atpackages/schemas/docs/generated/siax.agent-capability-policy.md). It structurally forces every policy document to:- Declare a tool allowlist (
read,comment,push_feature_branch,open_pr, ...) — absence is a denial. - Declare all seven governance capabilities
(
approve_pr,merge_pr,mutate_branch_protection,mutate_secrets,deploy_production,dns_mutation,credential_rotation) explicitly, and structurally forbidsgranted: truewithout a well-formed grant record shaped like an attribution (actor,reason,grantedAt). The schema can only check the shape — it cannot express "the grant's actor must differ fromsubject.identityRef" as a cross-field rule, so a self-issued grant (same identity as its own subject) still validates against the schema; the evaluator is what actually rejects it (DENIED_GRANT_SELF_ISSUED, see below). - Declare a network egress allowlist,
defaultDeny: truefixed byconst. - Declare secret scopes at a
none|read|writelevel, never a value. - Declare budgets —
maxActions/maxCommands/maxFiles/maxDurationMinutes/maxTokens/maxModelCostUsd, field names matchingsax3l/siax-automation'ssrc/devos/loops/loop-run.mjscheckCaps(). - Reference (never hardcode) the estate's canonical Product Firewall list
—
standard/estate/SIAX_PRODUCT_FIREWALL.yaml, the same filepackages/write-guard/src/product-firewall.mjsalready parses. - Name a subject the policy is issued to, structurally forbidden from being the most obvious shared-admin literals (defense in depth — see below for the real check).
- Declare a tool allowlist (
-
An evaluator (
src/evaluate-action.mjs) —evaluateAgentAction(policy, action, opts), a pure(-ish — it reads the committed Product Firewall YAML via@siax/write-guardunless a caller injects a fixture) decision function, fail-closed and short-circuiting on the first denial, same discipline asauthorize-repository-write.mjs. PlusevaluateEgressRequestandevaluateSecretAccessfor the network/secret dimensions. See the module's own header for the full step order and rationale. -
A CLI (
src/cli.mjs,bin: siax-agent-policy):siax-agent-policy validate --policy policy.json [--json] siax-agent-policy check-action --policy policy.json --repo siax-standard \ --capability merge_pr --actor-id simon \ --session-id sess-1 --run-id run-1 [--json] siax-agent-policy check-egress --policy policy.json --host git.siax.io --port 443 [--json] siax-agent-policy check-secret --policy policy.json --scope gitea/read-token --level read [--json]Exit codes:
0valid/allowed,1invalid/denied,2the command itself could not run (bad flags, unreadable file) — same three-way split as@siax/credential-scan's CLI, so a broken invocation never looks like a clean denial.
What this is NOT (read this before citing this package as "solved")
This is a prepared, consumable contract — not a deployed enforcement
point. Nothing in siax-standard can:
- Change Gitea's actual permission/impersonation model, or stop the shared
site-admin credential from being able to Sudo-impersonate any account.
That is
masterplan#25's open root-cause item, and requires Simon's action (an admin-enforcement setting, or moving agent identities off the shared account) — it is out of this repository's reach entirely. - Intercept a real
git push, a real Gitea API call, or a real CI job.evaluateAgentActiononly returns an answer; something has to call it, and mean it, before every privileged action. As ofpackages/doctor'sagent-policy-checksubcommand (below), that "something" now exists as a reachable, testedsiax-doctor-family preflight — but nothing in this estate's actual CI/ACT0/Gitea integration calls IT yet either. The gap moved one layer down, it did not close: a caller still has to know to shell out tosiax-doctor agent-policy-checkbefore a privileged action, same as it would have had to know to call the CLI directly. - Attribute the ONGOING incident (
masterplan#30) to an actor. This package makes the shared-credential pattern detectable going forward (an action carryingactorId: "admin"is a policy violation under this contract, reportable the moment something checks) — it does not retroactively identify who has been running the rogue process.
To make this a live control, at least one of the following would need to happen:
An ACT0 workflow step calls— partially done in this pass:siax-agent-policy check-action(or the library function directly) before every merge/deploy/branch-protection/ secret/DNS/credential step, and is wired to actually abort on a denialpackages/doctor's newagent-policy-checksubcommand (siax-doctor agent-policy-check --policy <path> --repo <r> --capability <c> --actor-id <id> --session-id <id> --run-id <id>) is that CLI preflight, wired into the samesiax-doctordispatcher and--fail-on=blockexit-code convention aswrite-check, tested end-to-end against both fresh fixtures and this package's own real committed fixtures (seepackages/doctor/test/commands/agent-policy-check.test.mjs). What is STILL not done, and still out of scope for this repository: an actual ACT0 workflow step, orsiax-automation's estate doctor, invoking that command before a real merge/deploy/branch-protection/secret/DNS/ credential step and aborting on its exit code.siax-standardcan provide the gate; it cannot place itself in front of Gitea/ACT0's real execution path from inside this repo.siax-automation's estate doctor (or a future credential-issuing system) is extended to issue per-session credentials that are actually SCOPED to asiax.agent-capability-policydocument, rather than every session sharing the one site-admin token — the structural fixsubjectKNOWN_SHARED_IDENTITIEShere are written IN ANTICIPATION of, not yet wired to.
- Gitea branch protection / required-reviewer settings are configured so
that an
approve_pr/merge_prdenial from this contract has something real to block against — today, permasterplan#25, protection is documented to be bypassable by the shared admin identity regardless.
Items 2 and 3 were not built or wired in this pass — building them would mean inventing a live credential-issuing system or a new Gitea integration, explicitly out of scope for this bounded PR.
Consuming this from another repo
Same blocker as @siax/write-guard and @siax/schemas: publishing to
git.siax.io's npm registry is blocked on a write:package-scoped token
(prompts/003). Until that's unblocked, consume this via the CLI
(siax-agent-policy, above), the same way @siax/write-guard is consumed
as siax-doctor write-check rather than as an npm dependency.
Testing
node --test packages/agent-policy/test/*.test.mjs
# or, from the repo root:
pnpm test:agent-policy
test/evaluate-action.test.mjs proves, with real assertions (not just
schema-shape checks):
- Default-deny actually denies — a policy with no explicit grant for
merge_pr(or any of the other six governance capabilities, individually) returnsDENIED_NOT_GRANTED. - An explicit grant is correctly honored — a well-formed, unexpired
grant with a matching
actorIdreturnsALLOWED; an expired one or one whose own grantor is a shared identity does not. - The Product-Firewall-reference case denies regardless of any other
grant (fail-closed even under a misconfigured policy) — proven twice:
once against an injected fixture repo with a perfectly well-formed
merge_prgrant for EVERY governance capability, and once against the REAL, committedstandard/estate/SIAX_PRODUCT_FIREWALL.yaml, so the claim holds against actual estate data, not only a synthetic example. - Shared-identity attribution denial (
admin/root/... as either the claimed actor or the grantor), identity-mismatch denial, budget vetoes, network-egress allow/deny, and secret-scope level checks.
test/cli.test.mjs exercises the real main() entry point end to end,
including all three exit-code classes.
Dependencies
Dependencies
| ID | Version |
|---|---|
| @siax/schemas | workspace:* |
| @siax/write-guard | workspace:* |