@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,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
|
+
}
|