@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.
- package/LICENSE +202 -0
- package/NOTICE +42 -0
- package/README.md +341 -0
- package/action/README.md +100 -0
- package/action/action.yml +134 -0
- package/action/report.mjs +144 -0
- package/bin/cirvix.mjs +1073 -0
- package/package.json +60 -0
- package/src/commands/demo.mjs +315 -0
- package/src/commands/init.mjs +558 -0
- package/src/commands/policy.mjs +345 -0
- package/src/commands/sarif.mjs +176 -0
- package/src/commands/scan.mjs +210 -0
- package/src/commands/status.mjs +208 -0
- package/src/commands/upgrade.mjs +162 -0
- package/src/core/approvals.mjs +388 -0
- package/src/core/audit.mjs +181 -0
- package/src/core/canonical.mjs +316 -0
- package/src/core/daemon.mjs +352 -0
- package/src/core/decisions.mjs +253 -0
- package/src/core/delegation.mjs +658 -0
- package/src/core/detect.mjs +337 -0
- package/src/core/entitlement-gate.mjs +100 -0
- package/src/core/entitlements.mjs +285 -0
- package/src/core/format.mjs +33 -0
- package/src/core/gateway.mjs +959 -0
- package/src/core/guard.mjs +568 -0
- package/src/core/http-transport.mjs +505 -0
- package/src/core/journal.mjs +419 -0
- package/src/core/jsonrpc.mjs +152 -0
- package/src/core/meter.mjs +225 -0
- package/src/core/normalize.mjs +516 -0
- package/src/core/notices.mjs +80 -0
- package/src/core/pipeline.mjs +629 -0
- package/src/core/policy-dsl.mjs +611 -0
- package/src/core/policy.mjs +710 -0
- package/src/core/prompts.mjs +146 -0
- package/src/core/risk.mjs +509 -0
- package/src/core/sanitize.mjs +279 -0
- package/src/core/secret-detect.mjs +533 -0
- package/src/core/secrets.mjs +312 -0
- package/src/core/uds.mjs +383 -0
- package/src/core/vault.mjs +530 -0
- package/src/index.mjs +143 -0
- 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
|
+
}
|