@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,145 @@
1
+ /**
2
+ * Policy testing.
3
+ *
4
+ * The point of this module is that a rule set is code, and code that decides
5
+ * what an agent may do deserves unit tests that run in CI next to everything
6
+ * else. `evaluate` is the same engine the gateway uses, called directly, with
7
+ * the context filled in so a test states the one thing it is about:
8
+ *
9
+ * test("production writes are held for a human", async () => {
10
+ * const decision = await evaluate({
11
+ * policyDir: "./policies",
12
+ * agent: "deploy-bot",
13
+ * action: "k8s.apply",
14
+ * resource: "production/checkout",
15
+ * context: { environment: "production" },
16
+ * });
17
+ * expect(decision.verdict).toBe("hold");
18
+ * expect(decision.approvers).toContain("platform-oncall");
19
+ * });
20
+ *
21
+ * The defaults matter. A test that has to spell out `path.insideWorkspace`
22
+ * every time will stop spelling it out correctly, and a policy test that
23
+ * passes because the context was wrong is worse than no test.
24
+ */
25
+
26
+ import { readdir, readFile } from "node:fs/promises";
27
+ import { extname, join } from "node:path";
28
+
29
+ import { evaluate as evaluateRules, parseRules, STARTER_RULES } from "./core/policy.mjs";
30
+
31
+ /**
32
+ * The context a call carries unless a test says otherwise.
33
+ *
34
+ * Chosen as the *permissive* baseline on purpose: inside the workspace, no
35
+ * external egress, no secret touched. A test asserting that something is
36
+ * denied should be denied by the rule it is testing, not by a restrictive
37
+ * default that would have denied anything.
38
+ */
39
+ const DEFAULT_CONTEXT = {
40
+ environment: "local",
41
+ path: { insideWorkspace: true },
42
+ egress: { external: false, allowlisted: false },
43
+ session: { touchedSecret: false },
44
+ };
45
+
46
+ /** Loads a rule set from a directory, a file, or an inline array. */
47
+ export async function loadPolicy({ rules, policy, policyFile, policyDir } = {}) {
48
+ if (Array.isArray(rules)) return parseRules(rules);
49
+
50
+ const file = policy ?? policyFile;
51
+ if (file) return parseRules(JSON.parse(await readFile(file, "utf8")));
52
+
53
+ if (policyDir) {
54
+ const entries = await readdir(policyDir);
55
+ const files = entries.filter((f) => [".json", ".policy"].includes(extname(f))).sort();
56
+ if (files.length === 0) {
57
+ throw new Error(`No policy files in ${policyDir}. Expected .json files.`);
58
+ }
59
+ const loaded = [];
60
+ for (const name of files) {
61
+ loaded.push(...parseRules(JSON.parse(await readFile(join(policyDir, name), "utf8"))));
62
+ }
63
+ // A duplicate name across files is a rule that silently shadows another,
64
+ // which is exactly the bug a directory of policies invites.
65
+ const seen = new Set();
66
+ for (const rule of loaded) {
67
+ if (seen.has(rule.name)) {
68
+ throw new Error(`Duplicate rule "${rule.name}" across files in ${policyDir}.`);
69
+ }
70
+ seen.add(rule.name);
71
+ }
72
+ return loaded;
73
+ }
74
+
75
+ return STARTER_RULES;
76
+ }
77
+
78
+ /**
79
+ * Evaluates one hypothetical call. Executes nothing.
80
+ *
81
+ * @returns {Promise<{verdict:string, rule:string|null, reason:string,
82
+ * remediation?:string, approvers?:string[], considered:Array, resource:string}>}
83
+ */
84
+ export async function evaluate({
85
+ rules,
86
+ policy,
87
+ policyFile,
88
+ policyDir,
89
+ agent = "test-agent",
90
+ action,
91
+ resource = "",
92
+ context = {},
93
+ cwd = process.cwd(),
94
+ } = {}) {
95
+ if (!action) throw new Error("evaluate needs an action, e.g. \"fs.read\".");
96
+ const ruleSet = await loadPolicy({ rules, policy, policyFile, policyDir });
97
+
98
+ return evaluateRules(
99
+ {
100
+ agent,
101
+ action,
102
+ resource,
103
+ context: {
104
+ ...DEFAULT_CONTEXT,
105
+ ...context,
106
+ path: { ...DEFAULT_CONTEXT.path, ...(context.path ?? {}) },
107
+ egress: { ...DEFAULT_CONTEXT.egress, ...(context.egress ?? {}) },
108
+ session: { ...DEFAULT_CONTEXT.session, ...(context.session ?? {}) },
109
+ },
110
+ },
111
+ ruleSet,
112
+ { cwd },
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Asserts a rule set does not permit more than it did.
118
+ *
119
+ * Point it at the calls you care about and it reports any that changed
120
+ * verdict — the unit-test counterpart to `cirvix replay`, for a policy diff in
121
+ * a pull request where there is no recorded run to replay against.
122
+ */
123
+ export async function expectNoLoosening({ before, after, calls, cwd = process.cwd() }) {
124
+ const previous = await loadPolicy(before);
125
+ const candidate = await loadPolicy(after);
126
+
127
+ const loosened = [];
128
+ for (const call of calls) {
129
+ const request = {
130
+ agent: call.agent ?? "test-agent",
131
+ action: call.action,
132
+ resource: call.resource ?? "",
133
+ context: { ...DEFAULT_CONTEXT, ...(call.context ?? {}) },
134
+ };
135
+ const was = evaluateRules(request, previous, { cwd }).verdict;
136
+ const now = evaluateRules(request, candidate, { cwd }).verdict;
137
+ // Only widening counts. A change that denies more is the direction a
138
+ // security policy is allowed to move without a reviewer being surprised.
139
+ const rank = { deny: 0, hold: 1, permit: 2 };
140
+ if (rank[now] > rank[was]) loosened.push({ ...call, was, now });
141
+ }
142
+ return { loosened, ok: loosened.length === 0 };
143
+ }
144
+
145
+ export { STARTER_RULES, parseRules };