@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,134 @@
1
+ # Cirvix — scan a repository for ungoverned AI agents.
2
+ #
3
+ # Published as `cirvix/scan`. A composite action rather than a JavaScript one,
4
+ # deliberately: a JS action ships a committed `dist/` bundle that has to be
5
+ # rebuilt on every change and is, in practice, the thing that goes stale. The
6
+ # CLI has zero runtime dependencies, so `npx` is a smaller and more honest
7
+ # supply chain than a vendored bundle nobody re-reads.
8
+ name: Cirvix AgentControl Scan
9
+ description: Find AI agent runtimes, MCP servers, and reachable credentials that nothing is governing.
10
+ author: Cirvix
11
+
12
+ branding:
13
+ icon: shield
14
+ color: green
15
+
16
+ inputs:
17
+ fail-on:
18
+ description: >-
19
+ Fail the job at this severity or above: high, medium, or low. Set to
20
+ "never" to report without failing — the right setting for the first run
21
+ on an existing repository, where the point is to see the baseline rather
22
+ than to block a merge on it.
23
+ required: false
24
+ default: high
25
+ working-directory:
26
+ description: Directory to scan. Defaults to the workspace root.
27
+ required: false
28
+ default: .
29
+ sarif-file:
30
+ description: Where to write SARIF. Set to an empty string to skip.
31
+ required: false
32
+ default: cirvix-scan.sarif
33
+ upload-sarif:
34
+ description: >-
35
+ Upload findings to GitHub code scanning, so they appear on the pull
36
+ request diff and in the Security tab rather than only in this log.
37
+ Requires `security-events: write`.
38
+ required: false
39
+ default: "true"
40
+ comment-on-pr:
41
+ description: Write the summary as a pull-request comment as well as a job summary.
42
+ required: false
43
+ default: "false"
44
+ version:
45
+ description: Version of @cirvix_ai/agent-control to run.
46
+ required: false
47
+ default: latest
48
+
49
+ outputs:
50
+ high:
51
+ description: Number of high-severity findings.
52
+ value: ${{ steps.scan.outputs.high }}
53
+ medium:
54
+ description: Number of medium-severity findings.
55
+ value: ${{ steps.scan.outputs.medium }}
56
+ low:
57
+ description: Number of low-severity findings.
58
+ value: ${{ steps.scan.outputs.low }}
59
+ findings:
60
+ description: The full scan result as JSON.
61
+ value: ${{ steps.scan.outputs.findings }}
62
+ passed:
63
+ description: "true when nothing at or above `fail-on` was found."
64
+ value: ${{ steps.scan.outputs.passed }}
65
+
66
+ runs:
67
+ using: composite
68
+ steps:
69
+ - name: Run Cirvix scan
70
+ id: scan
71
+ shell: bash
72
+ working-directory: ${{ inputs.working-directory }}
73
+ env:
74
+ CIRVIX_FAIL_ON: ${{ inputs.fail-on }}
75
+ CIRVIX_SARIF: ${{ inputs.sarif-file }}
76
+ CIRVIX_VERSION: ${{ inputs.version }}
77
+ run: |
78
+ set -euo pipefail
79
+
80
+ args=(--json)
81
+ if [ -n "${CIRVIX_SARIF}" ]; then
82
+ args+=(--sarif "${CIRVIX_SARIF}")
83
+ fi
84
+
85
+ # The scan's exit code is the signal, and `set -e` would take the job
86
+ # down before the summary is written. Captured, reported, then
87
+ # re-raised at the end by the reporter.
88
+ set +e
89
+ npx --yes "@cirvix_ai/agent-control@${CIRVIX_VERSION}" scan "${args[@]}" > cirvix-scan.json
90
+ scan_status=$?
91
+ set -e
92
+
93
+ if [ "${scan_status}" -ne 0 ] && [ ! -s cirvix-scan.json ]; then
94
+ echo "::error title=Cirvix::The scan did not run. Check that @cirvix_ai/agent-control@${CIRVIX_VERSION} exists."
95
+ exit 1
96
+ fi
97
+
98
+ node "${GITHUB_ACTION_PATH}/report.mjs" cirvix-scan.json
99
+
100
+ - name: Upload SARIF to code scanning
101
+ # `always()` so a failing scan still uploads. Findings that only appear
102
+ # when the build passes are findings nobody acts on.
103
+ if: ${{ always() && inputs.upload-sarif == 'true' && inputs.sarif-file != '' }}
104
+ continue-on-error: true
105
+ uses: github/codeql-action/upload-sarif@v3
106
+ with:
107
+ sarif_file: ${{ inputs.working-directory }}/${{ inputs.sarif-file }}
108
+ category: cirvix
109
+
110
+ - name: Comment on the pull request
111
+ if: ${{ always() && inputs.comment-on-pr == 'true' && github.event_name == 'pull_request' }}
112
+ continue-on-error: true
113
+ shell: bash
114
+ working-directory: ${{ inputs.working-directory }}
115
+ env:
116
+ GH_TOKEN: ${{ github.token }}
117
+ run: |
118
+ set -euo pipefail
119
+ if [ -f cirvix-summary.md ]; then
120
+ # `--edit-last` so a pushed branch updates one comment instead of
121
+ # accumulating a thread nobody reads to the bottom of.
122
+ gh pr comment "${{ github.event.pull_request.number }}" \
123
+ --body-file cirvix-summary.md --edit-last --create-if-none
124
+ fi
125
+
126
+ - name: Enforce the severity gate
127
+ if: ${{ always() && inputs.fail-on != 'never' }}
128
+ shell: bash
129
+ working-directory: ${{ inputs.working-directory }}
130
+ run: |
131
+ if [ "${{ steps.scan.outputs.passed }}" != "true" ]; then
132
+ echo "::error title=Cirvix::Findings at or above '${{ inputs.fail-on }}'. See the job summary."
133
+ exit 1
134
+ fi
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Turns a scan result into things a reviewer will actually see.
4
+ *
5
+ * Three surfaces, because a CI log is not one of them: a job summary somebody
6
+ * reads without expanding a step, inline annotations on the pull request, and
7
+ * step outputs so a workflow can branch on the numbers.
8
+ *
9
+ * Zero dependencies, like everything else that ships. An action that pulls a
10
+ * markdown library to build a table is an action with a supply chain.
11
+ */
12
+
13
+ import { appendFile, readFile, writeFile } from "node:fs/promises";
14
+
15
+ const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
16
+ const GATE = { high: ["high"], medium: ["high", "medium"], low: ["high", "medium", "low"] };
17
+ const ICON = { high: "🔴", medium: "🟠", low: "🟡" };
18
+
19
+ const [, , resultPath] = process.argv;
20
+
21
+ const result = JSON.parse(await readFile(resultPath ?? "cirvix-scan.json", "utf8"));
22
+ const findings = [...(result.findings ?? [])].sort(
23
+ (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],
24
+ );
25
+ const counts = result.counts ?? {};
26
+ const high = counts.high ?? 0;
27
+ const medium = counts.medium ?? 0;
28
+ const low = counts.low ?? 0;
29
+
30
+ const failOn = (process.env.CIRVIX_FAIL_ON ?? "high").toLowerCase();
31
+ const watched = GATE[failOn] ?? [];
32
+ const blocking = watched.reduce((n, level) => n + (counts[level] ?? 0), 0);
33
+ const passed = failOn === "never" || blocking === 0;
34
+
35
+ /* -- annotations ----------------------------------------------------------- */
36
+
37
+ // Only the blocking ones. Annotating every low finding on every pull request
38
+ // is how a team learns to scroll past the annotations, which costs more than
39
+ // the low findings were worth.
40
+ for (const finding of findings.filter((f) => watched.includes(f.severity))) {
41
+ const level = finding.severity === "high" ? "error" : "warning";
42
+ const title = `Cirvix: ${finding.subject}`;
43
+ const body = `${finding.detail}${finding.fix ? ` Fix: ${finding.fix}` : ""}`;
44
+ // GitHub's annotation format takes no newlines; %0A is the documented escape.
45
+ process.stdout.write(
46
+ `::${level} title=${escapeProperty(title)}::${escapeData(body)}\n`,
47
+ );
48
+ }
49
+
50
+ /* -- summary --------------------------------------------------------------- */
51
+
52
+ const verdict = passed
53
+ ? high + medium + low === 0
54
+ ? "Nothing ungoverned found."
55
+ : `No findings at or above **${failOn}**.`
56
+ : `**${blocking}** finding${blocking === 1 ? "" : "s"} at or above **${failOn}**.`;
57
+
58
+ const lines = [
59
+ "## Cirvix AgentControl",
60
+ "",
61
+ verdict,
62
+ "",
63
+ `| ${ICON.high} High | ${ICON.medium} Medium | ${ICON.low} Low |`,
64
+ "|---|---|---|",
65
+ `| ${high} | ${medium} | ${low} |`,
66
+ "",
67
+ ];
68
+
69
+ if (findings.length) {
70
+ lines.push(
71
+ "| | Finding | What it means | Fix |",
72
+ "|---|---|---|---|",
73
+ ...findings
74
+ .slice(0, 40)
75
+ .map(
76
+ (f) =>
77
+ `| ${ICON[f.severity] ?? ""} | \`${escapeCell(f.subject)}\` | ${escapeCell(f.detail)} | ${
78
+ f.fix ? `\`${escapeCell(f.fix)}\`` : "—"
79
+ } |`,
80
+ ),
81
+ );
82
+ // Silence about truncation reads as "that was all of them".
83
+ if (findings.length > 40) {
84
+ lines.push("", `_${findings.length - 40} further findings are in the SARIF upload._`);
85
+ }
86
+ } else {
87
+ lines.push(
88
+ "No agent runtime, MCP server, or reachable credential in this repository is currently ungoverned.",
89
+ );
90
+ }
91
+
92
+ lines.push(
93
+ "",
94
+ `<sub>Scanned ${result.cwd ?? "."} at ${result.scannedAt ?? "unknown time"}. `,
95
+ "Findings describe what an agent *could* reach, not what one has done. ",
96
+ "Nothing was executed and no code was sent anywhere.</sub>",
97
+ );
98
+
99
+ const summary = lines.join("\n");
100
+
101
+ if (process.env.GITHUB_STEP_SUMMARY) {
102
+ await appendFile(process.env.GITHUB_STEP_SUMMARY, summary + "\n", "utf8");
103
+ }
104
+ // Written unconditionally so the pull-request comment step has it whether or
105
+ // not a summary file exists — a local run of this script is a supported way to
106
+ // see what CI would say.
107
+ await writeFile("cirvix-summary.md", summary + "\n", "utf8");
108
+
109
+ /* -- outputs --------------------------------------------------------------- */
110
+
111
+ await setOutput("high", String(high));
112
+ await setOutput("medium", String(medium));
113
+ await setOutput("low", String(low));
114
+ await setOutput("passed", String(passed));
115
+ await setOutput("findings", JSON.stringify(result));
116
+
117
+ process.stdout.write(`\n${verdict.replace(/\*\*/g, "")}\n`);
118
+
119
+ /* -------------------------------------------------------------------------- */
120
+
121
+ async function setOutput(name, value) {
122
+ if (!process.env.GITHUB_OUTPUT) return;
123
+ // A multi-line value has to use the delimiter form, and the delimiter has to
124
+ // be one the value cannot contain — otherwise a finding containing the
125
+ // delimiter string can inject arbitrary outputs into the workflow.
126
+ const delimiter = `cirvix_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
127
+ await appendFile(
128
+ process.env.GITHUB_OUTPUT,
129
+ `${name}<<${delimiter}\n${value}\n${delimiter}\n`,
130
+ "utf8",
131
+ );
132
+ }
133
+
134
+ function escapeData(value) {
135
+ return String(value).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
136
+ }
137
+
138
+ function escapeProperty(value) {
139
+ return escapeData(value).replace(/:/g, "%3A").replace(/,/g, "%2C");
140
+ }
141
+
142
+ function escapeCell(value) {
143
+ return String(value).replace(/\|/g, "\\|").replace(/\n/g, " ");
144
+ }