@cirvix_ai/agent-control 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +42 -0
  3. package/README.md +341 -0
  4. package/action/README.md +100 -0
  5. package/action/action.yml +134 -0
  6. package/action/report.mjs +144 -0
  7. package/bin/cirvix.mjs +1073 -0
  8. package/package.json +60 -0
  9. package/src/commands/demo.mjs +315 -0
  10. package/src/commands/init.mjs +558 -0
  11. package/src/commands/policy.mjs +345 -0
  12. package/src/commands/sarif.mjs +176 -0
  13. package/src/commands/scan.mjs +210 -0
  14. package/src/commands/status.mjs +208 -0
  15. package/src/commands/upgrade.mjs +162 -0
  16. package/src/core/approvals.mjs +388 -0
  17. package/src/core/audit.mjs +181 -0
  18. package/src/core/canonical.mjs +316 -0
  19. package/src/core/daemon.mjs +352 -0
  20. package/src/core/decisions.mjs +253 -0
  21. package/src/core/delegation.mjs +658 -0
  22. package/src/core/detect.mjs +337 -0
  23. package/src/core/entitlement-gate.mjs +100 -0
  24. package/src/core/entitlements.mjs +285 -0
  25. package/src/core/format.mjs +33 -0
  26. package/src/core/gateway.mjs +959 -0
  27. package/src/core/guard.mjs +568 -0
  28. package/src/core/http-transport.mjs +505 -0
  29. package/src/core/journal.mjs +419 -0
  30. package/src/core/jsonrpc.mjs +152 -0
  31. package/src/core/meter.mjs +225 -0
  32. package/src/core/normalize.mjs +516 -0
  33. package/src/core/notices.mjs +80 -0
  34. package/src/core/pipeline.mjs +629 -0
  35. package/src/core/policy-dsl.mjs +611 -0
  36. package/src/core/policy.mjs +710 -0
  37. package/src/core/prompts.mjs +146 -0
  38. package/src/core/risk.mjs +509 -0
  39. package/src/core/sanitize.mjs +279 -0
  40. package/src/core/secret-detect.mjs +533 -0
  41. package/src/core/secrets.mjs +312 -0
  42. package/src/core/uds.mjs +383 -0
  43. package/src/core/vault.mjs +530 -0
  44. package/src/index.mjs +143 -0
  45. package/src/testing.mjs +145 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * The decision vocabulary.
3
+ *
4
+ * Five outcomes, one place. The policy engine, the gateway, the UDS runtime,
5
+ * the SDK, the audit record, and the console all name the same thing the same
6
+ * way, because a control plane where "hold" and "require_approval" are the same
7
+ * state under two names is a control plane whose logs cannot be joined.
8
+ *
9
+ * ALLOW forward the call unchanged
10
+ * DENY refuse it, legibly, with a remediation the agent can act on
11
+ * REQUIRE_APPROVAL suspend it for a named human; it may still happen
12
+ * SANITIZE forward it, but not as written — see below
13
+ * AUDIT_ONLY record what would have happened; do not enforce
14
+ *
15
+ * TWO OF THESE ARE NEW AND BOTH ARE EASY TO GET WRONG.
16
+ *
17
+ * SANITIZE is a permit with a transform attached, not a softer allow. It exists
18
+ * because the interesting cases are not "block this call" — they are "make this
19
+ * call safe": strip the credential the model pasted into an argument, drop the
20
+ * `IGNORE PREVIOUS INSTRUCTIONS` block out of a fetched web page before it
21
+ * reaches the context window. It therefore OUTRANKS a plain permit. If one rule
22
+ * says a payload must be cleaned and another says the call is fine, the call is
23
+ * fine *and the payload still gets cleaned*. Letting `permit` win there would
24
+ * mean any allow rule silently disables every sanitizer, which is the same
25
+ * class of bug as a permit overriding a forbid.
26
+ *
27
+ * AUDIT_ONLY is the one with a genuine security trap in it, so it is
28
+ * deliberately NOT a competing effect in the precedence chain.
29
+ *
30
+ * The naive design makes `audit_only` a rule effect that wins like any other.
31
+ * That hands anyone who can add a rule a bypass for every prohibition in the
32
+ * file: mark the tool `audit_only` and the forbid protecting it stops
33
+ * enforcing. So here:
34
+ *
35
+ * - A rule with effect `audit_only` OBSERVES. It records that it matched and
36
+ * contributes no authorization at all. It can never turn a deny into an
37
+ * allow, because it never produces an allow.
38
+ * - The AUDIT_ONLY *outcome* comes from engine mode, not from a rule. Running
39
+ * in `audit` mode computes the real verdict, records it, and suppresses
40
+ * enforcement — the honest shadow deployment. It is a whole-engine setting,
41
+ * visible in `cirvix status`, and every record written in that mode carries
42
+ * `enforced: false` so nobody later mistakes a shadow run for a protected
43
+ * one.
44
+ */
45
+
46
+ import { RISK, riskAtLeast } from "./risk.mjs";
47
+
48
+ /** The five outcomes. */
49
+ export const DECISION = {
50
+ ALLOW: "allow",
51
+ DENY: "deny",
52
+ REQUIRE_APPROVAL: "require_approval",
53
+ SANITIZE: "sanitize",
54
+ AUDIT_ONLY: "audit_only",
55
+ };
56
+
57
+ /**
58
+ * Rule effects, which are not the same set as outcomes.
59
+ *
60
+ * `forbid`/`permit`/`hold` are the existing on-disk vocabulary and are kept
61
+ * verbatim — policy files in the wild use them and renaming them would be a
62
+ * silent breaking change to every deployed rule set.
63
+ */
64
+ export const EFFECT = {
65
+ PERMIT: "permit",
66
+ FORBID: "forbid",
67
+ HOLD: "hold",
68
+ SANITIZE: "sanitize",
69
+ AUDIT_ONLY: "audit_only",
70
+ };
71
+
72
+ /**
73
+ * Legacy verdicts. The engine still returns these; `toDecision` widens them.
74
+ * Kept as a separate name rather than aliased so it is obvious at a call site
75
+ * which vocabulary is in play.
76
+ */
77
+ export const VERDICT = { PERMIT: "permit", DENY: "deny", HOLD: "hold" };
78
+
79
+ const VERDICT_TO_DECISION = {
80
+ permit: DECISION.ALLOW,
81
+ deny: DECISION.DENY,
82
+ hold: DECISION.REQUIRE_APPROVAL,
83
+ sanitize: DECISION.SANITIZE,
84
+ audit_only: DECISION.AUDIT_ONLY,
85
+ };
86
+
87
+ const DECISION_TO_VERDICT = {
88
+ [DECISION.ALLOW]: VERDICT.PERMIT,
89
+ [DECISION.SANITIZE]: VERDICT.PERMIT,
90
+ [DECISION.AUDIT_ONLY]: VERDICT.PERMIT,
91
+ [DECISION.DENY]: VERDICT.DENY,
92
+ [DECISION.REQUIRE_APPROVAL]: VERDICT.HOLD,
93
+ };
94
+
95
+ export function toDecision(verdict) {
96
+ return VERDICT_TO_DECISION[String(verdict ?? "").toLowerCase()] ?? DECISION.DENY;
97
+ }
98
+
99
+ /**
100
+ * Narrows a decision to the three-state verdict the transports understand.
101
+ *
102
+ * SANITIZE and AUDIT_ONLY both narrow to `permit`, because both forward the
103
+ * call — the difference lives in the flags that ride alongside, not in whether
104
+ * the call proceeds. A transport that only knows permit/deny/hold therefore
105
+ * behaves correctly by default instead of failing open on a value it has never
106
+ * seen.
107
+ */
108
+ export function toVerdict(decision) {
109
+ return DECISION_TO_VERDICT[String(decision ?? "").toLowerCase()] ?? VERDICT.DENY;
110
+ }
111
+
112
+ /** True when the call proceeds to the upstream in some form. */
113
+ export function isForwarded(decision) {
114
+ return (
115
+ decision === DECISION.ALLOW ||
116
+ decision === DECISION.SANITIZE ||
117
+ decision === DECISION.AUDIT_ONLY
118
+ );
119
+ }
120
+
121
+ /** True when a human can still turn this into a forwarded call. */
122
+ export function isAppealable(decision) {
123
+ return decision === DECISION.REQUIRE_APPROVAL;
124
+ }
125
+
126
+ /* -------------------------------------------------------------------------- */
127
+ /* Precedence */
128
+ /* -------------------------------------------------------------------------- */
129
+
130
+ /**
131
+ * Most authoritative first.
132
+ *
133
+ * `audit_only` is absent on purpose — see the header. It is an observation, and
134
+ * observations do not compete with decisions.
135
+ */
136
+ export const EFFECT_PRECEDENCE = [
137
+ EFFECT.FORBID,
138
+ EFFECT.HOLD,
139
+ EFFECT.SANITIZE,
140
+ EFFECT.PERMIT,
141
+ ];
142
+
143
+ export function effectRank(effect) {
144
+ const i = EFFECT_PRECEDENCE.indexOf(String(effect ?? "").toLowerCase());
145
+ // Unknown effects rank last and are treated as contributing nothing, which
146
+ // keeps a policy file from a newer version from loosening an older engine.
147
+ return i === -1 ? EFFECT_PRECEDENCE.length : i;
148
+ }
149
+
150
+ /** True when `a` beats `b`. Equal effects tie and first-match wins. */
151
+ export function outranks(a, b) {
152
+ return effectRank(a) < effectRank(b);
153
+ }
154
+
155
+ /* -------------------------------------------------------------------------- */
156
+ /* Engine mode */
157
+ /* -------------------------------------------------------------------------- */
158
+
159
+ export const MODE = {
160
+ /** Decisions are enforced. The default, and the only mode that protects anything. */
161
+ ENFORCE: "enforce",
162
+ /** Decisions are computed and recorded; nothing is blocked. */
163
+ AUDIT: "audit",
164
+ };
165
+
166
+ /**
167
+ * Applies engine mode to a decision.
168
+ *
169
+ * In `audit` mode the computed decision is preserved as `wouldHave` and the
170
+ * effective decision becomes AUDIT_ONLY, with `enforced: false`. Nothing is
171
+ * discarded: the point of a shadow deployment is to answer "what would this
172
+ * rule set have broken", and that question needs the original answer intact.
173
+ *
174
+ * @returns {object} the decision, possibly downgraded, always carrying `enforced`
175
+ */
176
+ export function applyMode(decision, mode = MODE.ENFORCE) {
177
+ const computed = decision.decision ?? toDecision(decision.verdict);
178
+
179
+ if (mode !== MODE.AUDIT) {
180
+ return { ...decision, decision: computed, enforced: true, mode: MODE.ENFORCE };
181
+ }
182
+
183
+ return {
184
+ ...decision,
185
+ decision: DECISION.AUDIT_ONLY,
186
+ // The transports read `verdict`; in audit mode every call must proceed.
187
+ verdict: VERDICT.PERMIT,
188
+ enforced: false,
189
+ mode: MODE.AUDIT,
190
+ wouldHave: {
191
+ decision: computed,
192
+ verdict: decision.verdict,
193
+ rule: decision.rule ?? null,
194
+ reason: decision.reason ?? null,
195
+ },
196
+ reason:
197
+ computed === DECISION.ALLOW
198
+ ? decision.reason
199
+ : `Audit mode: this call was allowed to proceed. Enforcing, it would have been ${String(computed).toUpperCase()} by ${decision.rule ?? "default-deny"}.`,
200
+ };
201
+ }
202
+
203
+ /* -------------------------------------------------------------------------- */
204
+ /* Risk posture */
205
+ /* -------------------------------------------------------------------------- */
206
+
207
+ /**
208
+ * Raises a decision to meet a risk floor.
209
+ *
210
+ * The risk engine's posture is a FLOOR, never a ceiling: it can escalate an
211
+ * allow to an approval, and it can never de-escalate a deny to an allow. That
212
+ * asymmetry is what lets the risk table be tuned without anyone auditing it as
213
+ * a second, shadow policy — the worst it can do is ask for more scrutiny than
214
+ * necessary.
215
+ *
216
+ * A rule that explicitly names the call wins over the floor, because the
217
+ * operator who wrote it knew something a generic table cannot. `escalate` is
218
+ * therefore applied only when the decision came from default-deny or from a
219
+ * rule that opted in with `respectRiskFloor`.
220
+ */
221
+ export function escalateForRisk(decision, risk, { floor = RISK.HIGH } = {}) {
222
+ if (!risk || decision.decision === DECISION.DENY) return decision;
223
+ if (decision.explicit && !decision.respectRiskFloor) return decision;
224
+
225
+ if (risk.level === RISK.CRITICAL) {
226
+ return {
227
+ ...decision,
228
+ decision: DECISION.DENY,
229
+ verdict: VERDICT.DENY,
230
+ riskEscalated: true,
231
+ reason: `Risk is CRITICAL and no rule explicitly permits it. ${risk.reason}`,
232
+ remediation:
233
+ "If this call is intended, add an explicit permit rule naming it — a CRITICAL call should never be allowed by a wildcard.",
234
+ };
235
+ }
236
+
237
+ if (riskAtLeast(risk.level, floor) && decision.decision === DECISION.ALLOW) {
238
+ return {
239
+ ...decision,
240
+ decision: DECISION.REQUIRE_APPROVAL,
241
+ verdict: VERDICT.HOLD,
242
+ riskEscalated: true,
243
+ reason: `Risk is ${String(risk.level).toUpperCase()}, which requires approval under the current posture. ${risk.reason}`,
244
+ };
245
+ }
246
+
247
+ return decision;
248
+ }
249
+
250
+ /** Terminal-friendly label. One spelling, everywhere. */
251
+ export function decisionLabel(decision) {
252
+ return String(decision ?? "unknown").toUpperCase().replace(/_/g, " ");
253
+ }