peer-ai 1.0.0-next.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +403 -0
  3. package/dist/assess.d.ts +102 -0
  4. package/dist/assess.js +545 -0
  5. package/dist/check.d.ts +30 -0
  6. package/dist/check.js +253 -0
  7. package/dist/checks.d.ts +15 -0
  8. package/dist/checks.js +15 -0
  9. package/dist/cli.d.ts +10 -0
  10. package/dist/cli.js +231 -0
  11. package/dist/detect.d.ts +42 -0
  12. package/dist/detect.js +459 -0
  13. package/dist/doctor.d.ts +36 -0
  14. package/dist/doctor.js +297 -0
  15. package/dist/document.d.ts +30 -0
  16. package/dist/document.js +72 -0
  17. package/dist/enforcers.d.ts +6 -0
  18. package/dist/enforcers.js +307 -0
  19. package/dist/feedback.d.ts +67 -0
  20. package/dist/feedback.js +209 -0
  21. package/dist/files.d.ts +1 -0
  22. package/dist/files.js +72 -0
  23. package/dist/init.d.ts +31 -0
  24. package/dist/init.js +158 -0
  25. package/dist/mcp.d.ts +13 -0
  26. package/dist/mcp.js +247 -0
  27. package/dist/package-info.d.ts +4 -0
  28. package/dist/package-info.js +6 -0
  29. package/dist/pipeline.d.ts +82 -0
  30. package/dist/pipeline.js +265 -0
  31. package/dist/prompter.d.ts +23 -0
  32. package/dist/prompter.js +56 -0
  33. package/dist/render.d.ts +58 -0
  34. package/dist/render.js +557 -0
  35. package/dist/report.d.ts +3 -0
  36. package/dist/report.js +93 -0
  37. package/dist/routing.d.ts +24 -0
  38. package/dist/routing.js +121 -0
  39. package/dist/ruff.d.ts +19 -0
  40. package/dist/ruff.js +64 -0
  41. package/dist/standards.d.ts +46 -0
  42. package/dist/standards.js +130 -0
  43. package/dist/state.d.ts +22 -0
  44. package/dist/state.js +56 -0
  45. package/dist/test-helpers.d.ts +16 -0
  46. package/dist/test-helpers.js +62 -0
  47. package/dist/work.d.ts +147 -0
  48. package/dist/work.js +357 -0
  49. package/package.json +45 -0
@@ -0,0 +1,265 @@
1
+ // The github-actions stack profile's checks, as the workflow render writes (RFC 0006, section 4).
2
+ // Each tool is a release checked against its published checksum, or an image pinned to its
3
+ // digest, and Semgrep's rules are pinned to a commit. Each job runs one tool on the repository.
4
+ // The same commands run in Peer AI's own CI on every rule's failing and passing examples, so
5
+ // what's proven is what a project runs.
6
+ //
7
+ // Job names never change, since a project makes them required checks: a tool's version or the
8
+ // rules a job covers appear only in its steps.
9
+ import { createHash } from "node:crypto";
10
+ import { PROFILES, profileRulesFor } from "peer-ai-standards";
11
+ export const WORKFLOW_FILE = ".github/workflows/peer-ai-security.yml";
12
+ /** The folder the tools are installed in, on the PATH of the job's later steps. */
13
+ const TOOLS = '"$RUNNER_TEMP/peer-ai-tools"';
14
+ const CHECKOUT = "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1";
15
+ const UPLOAD = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1";
16
+ const GITLEAKS = {
17
+ version: "8.30.1",
18
+ url: "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz",
19
+ sha256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb",
20
+ };
21
+ const OSV_SCANNER = {
22
+ version: "2.6.0",
23
+ url: "https://github.com/google/osv-scanner/releases/download/v2.6.0/osv-scanner_linux_amd64",
24
+ sha256: "ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108",
25
+ };
26
+ export const IMAGES = {
27
+ zizmor: {
28
+ name: "ghcr.io/zizmorcore/zizmor",
29
+ tag: "1.30.1",
30
+ digest: "sha256:a2eb396d886c053073405c7a980f2139ba2248ec172243cfa3841e57196e8101",
31
+ },
32
+ semgrep: {
33
+ name: "semgrep/semgrep",
34
+ tag: "1.178.0",
35
+ digest: "sha256:32e459968daabe7ab86968184a29109b9564aa00392401156f9788452b42786b",
36
+ },
37
+ sslyze: {
38
+ name: "nablac0d3/sslyze",
39
+ tag: "6.3.1",
40
+ digest: "sha256:3060ce2f3168cf1d74e5a0989660008052bee645826875122ba7edea95e5b95c",
41
+ },
42
+ zap: {
43
+ name: "ghcr.io/zaproxy/zaproxy",
44
+ tag: "2.17.0",
45
+ digest: "sha256:781a2bdaea47324e7bab583e2263f21d257b0aee61ed51521a5be45f5f5081ef",
46
+ },
47
+ };
48
+ export const imageRef = (image) => `${image.name}:${image.tag}@${image.digest}`;
49
+ /** Semgrep's security rules for the common languages, at one commit, which git checks by its hash. */
50
+ const SEMGREP_RULES = {
51
+ repository: "https://github.com/semgrep/semgrep-rules",
52
+ commit: "07135b5255b20ad6c29967215422b1ecc1f03988",
53
+ folders: ["python", "javascript", "typescript", "go", "java", "ruby", "php", "csharp"].map((language) => `${language}/lang/security`),
54
+ };
55
+ export const JOBS = {
56
+ secrets: {
57
+ name: "peer-ai / secrets",
58
+ tool: `Gitleaks ${GITLEAKS.version}`,
59
+ install: [
60
+ `curl -sSfL -o ${TOOLS}/gitleaks.tar.gz ${GITLEAKS.url}`,
61
+ `echo "${GITLEAKS.sha256} $RUNNER_TEMP/peer-ai-tools/gitleaks.tar.gz" | sha256sum --check --quiet`,
62
+ `tar -xzf ${TOOLS}/gitleaks.tar.gz -C ${TOOLS} gitleaks`,
63
+ ],
64
+ // A pull request's own commits; every other run, the whole history. A secret already replaced
65
+ // is recorded in .gitleaksignore, with why.
66
+ scan: [
67
+ 'if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then',
68
+ ' gitleaks git --no-banner --redact --verbose --exit-code 1 --log-opts="$BASE_SHA..$HEAD_SHA" .',
69
+ "else",
70
+ " gitleaks git --no-banner --redact --verbose --exit-code 1 .",
71
+ "fi",
72
+ ],
73
+ history: true,
74
+ env: {
75
+ BASE_SHA: "${{ github.event.pull_request.base.sha }}",
76
+ HEAD_SHA: "${{ github.event.pull_request.head.sha }}",
77
+ },
78
+ },
79
+ dependencies: {
80
+ name: "peer-ai / dependencies",
81
+ tool: `OSV-Scanner ${OSV_SCANNER.version}`,
82
+ install: [
83
+ `curl -sSfL -o ${TOOLS}/osv-scanner ${OSV_SCANNER.url}`,
84
+ `echo "${OSV_SCANNER.sha256} $RUNNER_TEMP/peer-ai-tools/osv-scanner" | sha256sum --check --quiet`,
85
+ `chmod +x ${TOOLS}/osv-scanner`,
86
+ ],
87
+ // Exit code 128 means it found nothing it can read: not a vulnerability, but said out loud.
88
+ scan: [
89
+ "status=0",
90
+ "osv-scanner scan source --recursive . || status=$?",
91
+ 'if [ "$status" -eq 128 ]; then',
92
+ ' echo "::warning::OSV-Scanner found no lockfile or manifest it can read, so no dependency was checked."',
93
+ 'elif [ "$status" -ne 0 ]; then',
94
+ ' exit "$status"',
95
+ "fi",
96
+ ],
97
+ },
98
+ workflows: {
99
+ name: "peer-ai / workflows",
100
+ tool: `zizmor ${IMAGES.zizmor.tag}`,
101
+ install: [`docker pull ${imageRef(IMAGES.zizmor)}`],
102
+ // The whole repository, so actions kept in it are checked as well as the workflows.
103
+ scan: [`docker run --rm -v "$PWD:/repo:ro" -w /repo ${imageRef(IMAGES.zizmor)} --offline .`],
104
+ },
105
+ code: {
106
+ name: "peer-ai / code",
107
+ tool: `Semgrep ${IMAGES.semgrep.tag}`,
108
+ install: [
109
+ `git init -q ${TOOLS}/semgrep-rules`,
110
+ `git -C ${TOOLS}/semgrep-rules fetch -q --depth 1 ${SEMGREP_RULES.repository} ${SEMGREP_RULES.commit}`,
111
+ `git -C ${TOOLS}/semgrep-rules checkout -q FETCH_HEAD`,
112
+ `docker pull ${imageRef(IMAGES.semgrep)}`,
113
+ ],
114
+ scan: [
115
+ [
116
+ `docker run --rm -v "$PWD:/src:ro" -v ${TOOLS}/semgrep-rules:/rules:ro -w /src ${imageRef(IMAGES.semgrep)}`,
117
+ "semgrep scan",
118
+ ...SEMGREP_RULES.folders.map((folder) => `--config /rules/${folder}`),
119
+ "--error --metrics off --quiet .",
120
+ ].join(" "),
121
+ ],
122
+ },
123
+ };
124
+ /** A value quoted for the shell, whatever it holds. */
125
+ const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
126
+ /**
127
+ * The environments' addresses, split into those the pipeline can use and those it can't: an
128
+ * address that isn't a full http or https URL, or that holds a user name or password, which would
129
+ * be written into the committed workflow.
130
+ */
131
+ export function environmentAddresses(config) {
132
+ const usable = [];
133
+ const unusable = [];
134
+ for (const environment of config.environments ?? []) {
135
+ if (environment.url === undefined)
136
+ continue;
137
+ const url = URL.canParse(environment.url) ? new URL(environment.url) : undefined;
138
+ const fits = url !== undefined && ["http:", "https:"].includes(url.protocol) && url.username === "" && url.password === "";
139
+ if (!fits) {
140
+ unusable.push({ id: environment.id, url: environment.url });
141
+ continue;
142
+ }
143
+ usable.push({ id: environment.id, url: url.href, host: url.host, production: environment.production });
144
+ }
145
+ return { usable, unusable };
146
+ }
147
+ const isWholeProject = (id) => PROFILES.find((profile) => profile.id === id)?.stacks.length === 0;
148
+ /** The pipeline rules that apply at the project's stage, leaving out the ones it set aside. */
149
+ export function pipelineRules(config) {
150
+ const setAside = new Set((config.standards?.exceptions ?? []).map((exception) => exception.rule));
151
+ return profileRulesFor({
152
+ listed: (config.standards?.profiles ?? []).filter(isWholeProject),
153
+ stage: config.project.stage ?? "mvp",
154
+ traits: config.project.traits ?? [],
155
+ }).filter((rule) => rule.profile === "github-actions" && !setAside.has(rule.id));
156
+ }
157
+ const indent = (lines, by) => lines.map((line) => `${" ".repeat(by)}${line}`);
158
+ /** One job: check out the repository, install the tool, run it. */
159
+ function job(id, script, rules) {
160
+ return [
161
+ ` ${id}:`,
162
+ ` name: ${script.name}`,
163
+ " runs-on: ubuntu-latest",
164
+ " permissions:",
165
+ " contents: read",
166
+ " steps:",
167
+ ` - uses: ${CHECKOUT}`,
168
+ " with:",
169
+ ...(script.history === true ? [" fetch-depth: 0"] : []),
170
+ " persist-credentials: false",
171
+ ` - name: Install ${script.tool}`,
172
+ " run: |",
173
+ ...indent(['mkdir -p "$RUNNER_TEMP/peer-ai-tools"', 'echo "$RUNNER_TEMP/peer-ai-tools" >> "$GITHUB_PATH"'], 10),
174
+ ...indent(script.install, 10),
175
+ ` - name: Check ${rules.join(", ")}`,
176
+ ...(script.env === undefined
177
+ ? []
178
+ : [" env:", ...Object.entries(script.env).map(([key, value]) => ` ${key}: ${value}`)]),
179
+ " run: |",
180
+ ...indent(script.scan, 10),
181
+ ];
182
+ }
183
+ const SCHEDULED = " if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'";
184
+ /** The workflow's jobs for the rules that apply, and the environments with usable addresses. */
185
+ function jobs(config) {
186
+ const rules = pipelineRules(config);
187
+ const lines = [];
188
+ for (const id of Object.keys(JOBS)) {
189
+ const covered = rules.filter((rule) => rule.enforcer?.tool === "github-actions" && rule.enforcer.job === id);
190
+ if (covered.length > 0)
191
+ lines.push(...job(id, JOBS[id], covered.map((rule) => rule.id)));
192
+ }
193
+ const ids = new Set(rules.map((rule) => rule.id));
194
+ const { usable } = environmentAddresses(config);
195
+ if (ids.has("GHA-06") && usable.length > 0) {
196
+ lines.push(" tls:", " name: peer-ai / tls", " runs-on: ubuntu-latest", SCHEDULED, " permissions: {}", " steps:", ` - name: Check GHA-06 with SSLyze ${IMAGES.sslyze.tag}, against Mozilla's intermediate profile`, ` run: docker run --rm ${imageRef(IMAGES.sslyze)} --mozilla_config=intermediate ${usable.map((address) => quote(address.host)).join(" ")}`);
197
+ }
198
+ // Only an environment the config marks as not production is scanned: one it doesn't mark might be.
199
+ const staging = usable.filter((address) => address.production === false);
200
+ if (ids.has("GHA-07") && staging.length > 0) {
201
+ lines.push(" running-app:", " name: peer-ai / running-app", " runs-on: ubuntu-latest", SCHEDULED, " permissions:", " contents: read", " steps:", ` - uses: ${CHECKOUT}`, " with:", " persist-credentials: false", " - name: Prepare the scan, with the accepted findings in .github/zap-rules.tsv", " run: |", " mkdir -p zap && chmod 777 zap", " if [ -f .github/zap-rules.tsv ]; then cp .github/zap-rules.tsv zap/; fi", ...staging.flatMap((address) => [
202
+ ` - name: Check GHA-07 with OWASP ZAP ${IMAGES.zap.tag} in ${address.id}, which isn't production`,
203
+ " run: |",
204
+ " if [ -f zap/zap-rules.tsv ]; then set -- -c zap-rules.tsv; fi",
205
+ ` docker run --rm -v "$PWD/zap:/zap/wrk:rw" ${imageRef(IMAGES.zap)} zap-baseline.py -t ${quote(address.url)} -J ${quote(`zap-${address.id}.json`)} "$@"`,
206
+ ]), ` - uses: ${UPLOAD}`, " if: always()", " with:", " name: zap-reports", " path: zap/*.json");
207
+ }
208
+ return lines;
209
+ }
210
+ /** The workflow's body, below its header, or undefined when no job applies. */
211
+ export function workflowBody(config) {
212
+ const body = jobs(config);
213
+ if (body.length === 0)
214
+ return undefined;
215
+ const branch = config.repo?.defaultBranch;
216
+ return [
217
+ "name: Peer AI security checks",
218
+ "on:",
219
+ " pull_request:",
220
+ // With no default branch in the config, changes are checked in their pull requests.
221
+ ...(branch === undefined ? [] : [" push:", ` branches: [${branch}]`]),
222
+ " schedule:",
223
+ ' - cron: "23 4 * * *"',
224
+ " workflow_dispatch:",
225
+ "permissions: {}",
226
+ "jobs:",
227
+ ...body,
228
+ "",
229
+ ].join("\n");
230
+ }
231
+ /** The jobs the workflow render writes would run: the keys under jobs:. */
232
+ export function workflowJobs(config) {
233
+ return [...(workflowBody(config) ?? "").matchAll(/^ {2}([a-z-]+):$/gm)]
234
+ .map((match) => match[1] ?? "")
235
+ .filter((id) => !["pull_request", "push", "schedule", "workflow_dispatch"].includes(id));
236
+ }
237
+ const normalise = (text) => text.replaceAll("\r\n", "\n");
238
+ const hash = (body) => createHash("sha256").update(normalise(body)).digest("hex").slice(0, 16);
239
+ const HASH_LINE = /^# peer-ai sha256: ([0-9a-f]{16})$/m;
240
+ /** The whole file render writes: a header recording the body's hash, then the body. */
241
+ export function workflowFile(config) {
242
+ const body = workflowBody(config);
243
+ if (body === undefined)
244
+ return undefined;
245
+ return [
246
+ "# Generated by peer-ai render from peer-ai.config.json: the checks of the github-actions stack profile.",
247
+ "# Change the config, not this file. Render writes it again while it's as render left it, and leaves",
248
+ "# it alone once someone changes it by hand. Make each job a required check: their names never change.",
249
+ `# peer-ai sha256: ${hash(body)}`,
250
+ body,
251
+ ].join("\n");
252
+ }
253
+ /** Whether a file was written by render: it records a hash in its header. */
254
+ export const writtenByRender = (content) => HASH_LINE.test(normalise(content));
255
+ /** Whether a file is as render wrote it: its body still has the hash its header records. */
256
+ export function unchangedSinceRender(content) {
257
+ const text = normalise(content);
258
+ const recorded = HASH_LINE.exec(text)?.[1];
259
+ if (recorded === undefined)
260
+ return false;
261
+ const body = text.slice(text.indexOf("\n", text.search(HASH_LINE)) + 1);
262
+ return hash(body) === recorded;
263
+ }
264
+ /** Whether two files are the same apart from their line endings. */
265
+ export const sameFile = (a, b) => normalise(a) === normalise(b);
@@ -0,0 +1,23 @@
1
+ export interface Choice<T extends string> {
2
+ value: T;
3
+ label: string;
4
+ hint?: string;
5
+ }
6
+ export interface Prompter {
7
+ intro(title: string): void;
8
+ note(message: string, title?: string): void;
9
+ text(message: string, options?: {
10
+ initial?: string;
11
+ placeholder?: string;
12
+ required?: boolean;
13
+ }): Promise<string>;
14
+ select<T extends string>(message: string, choices: Choice<T>[], initial?: T): Promise<T>;
15
+ multiselect<T extends string>(message: string, choices: Choice<T>[]): Promise<T[]>;
16
+ confirm(message: string, initial?: boolean): Promise<boolean>;
17
+ outro(message: string): void;
18
+ }
19
+ /** Thrown when the user cancels a question, so nothing is written. */
20
+ export declare class Cancelled extends Error {
21
+ constructor();
22
+ }
23
+ export declare function createTerminalPrompter(): Prompter;
@@ -0,0 +1,56 @@
1
+ // The questions `init` asks go through this interface, so tests can answer them without a
2
+ // terminal and a future non-terminal front end can too.
3
+ import * as clack from "@clack/prompts";
4
+ /** Thrown when the user cancels a question, so nothing is written. */
5
+ export class Cancelled extends Error {
6
+ constructor() {
7
+ super("cancelled");
8
+ }
9
+ }
10
+ function answer(value) {
11
+ if (clack.isCancel(value) || typeof value === "symbol")
12
+ throw new Cancelled();
13
+ return value;
14
+ }
15
+ function options(choices) {
16
+ return choices.map((choice) => ({
17
+ value: choice.value,
18
+ label: choice.label,
19
+ ...(choice.hint === undefined ? {} : { hint: choice.hint }),
20
+ }));
21
+ }
22
+ export function createTerminalPrompter() {
23
+ return {
24
+ intro: (title) => {
25
+ clack.intro(title);
26
+ },
27
+ note: (message, title) => {
28
+ clack.note(message, title);
29
+ },
30
+ text: async (message, settings = {}) => {
31
+ const value = await clack.text({
32
+ message,
33
+ ...(settings.placeholder === undefined ? {} : { placeholder: settings.placeholder }),
34
+ ...(settings.initial === undefined ? {} : { initialValue: settings.initial }),
35
+ validate: (input) => (settings.required === true && (input ?? "").trim() === "" ? "Required" : undefined),
36
+ });
37
+ return answer(value).trim();
38
+ },
39
+ select: async (message, choices, initial) => {
40
+ const value = await clack.select({
41
+ message,
42
+ options: options(choices),
43
+ ...(initial === undefined ? {} : { initialValue: initial }),
44
+ });
45
+ return answer(value);
46
+ },
47
+ multiselect: async (message, choices) => {
48
+ const value = await clack.multiselect({ message, options: options(choices), required: false });
49
+ return answer(value);
50
+ },
51
+ confirm: async (message, initial = true) => answer(await clack.confirm({ message, initialValue: initial })),
52
+ outro: (message) => {
53
+ clack.outro(message);
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,58 @@
1
+ import type { PeerAiConfig, ToolId } from "peer-ai-workflow";
2
+ import type { Output } from "./init.ts";
3
+ export declare const START = "<!-- peer-ai:start -->";
4
+ export declare const END = "<!-- peer-ai:end -->";
5
+ /** What render does to one file. `content` is the whole file to write, when it changes. */
6
+ export interface Planned {
7
+ path: string;
8
+ /** kept: a file render owns that someone changed by hand, which render leaves alone on purpose. */
9
+ action: "create" | "update" | "unchanged" | "refused" | "kept";
10
+ content?: string;
11
+ note?: string;
12
+ }
13
+ /** What render does to one skill's folder. */
14
+ export interface PlannedSkill {
15
+ /** The folder, such as .claude/skills/peer-ai-security-review. */
16
+ path: string;
17
+ action: "create" | "update" | "unchanged" | "remove";
18
+ /** Every file the folder should hold, by its path inside the folder. */
19
+ files: Map<string, string>;
20
+ /** Files in the folder that shouldn't be there any more. */
21
+ stale: string[];
22
+ }
23
+ export interface RenderPlan {
24
+ files: Planned[];
25
+ skills: PlannedSkill[];
26
+ /** Steps render can't take inside the project, such as a tool that keeps servers in the user's own config. */
27
+ manual: string[];
28
+ }
29
+ /** How AI tools start the server: the project's own pinned copy, or this exact version. */
30
+ export declare function serverCommand(root: string): {
31
+ command: string;
32
+ args: string[];
33
+ };
34
+ /**
35
+ * The command that writes only the skills, for a session or a cloud agent to run before it starts.
36
+ * It uses the same peer-ai the MCP server does: the project's pinned copy, or this exact version.
37
+ */
38
+ export declare function skillsCommand(root: string): string;
39
+ /** The instructions every tool gets: short, because the MCP server serves the detail on demand. */
40
+ export declare function instructions(config: PeerAiConfig): string;
41
+ /**
42
+ * Where the tools in the config read skills. Codex, Cursor, Copilot and Gemini CLI read the shared
43
+ * .agents/skills; Claude Code reads .claude/skills, which Cursor and Copilot also read. So each
44
+ * skill is written as few times as the tools allow, and not at all when no tool is listed.
45
+ */
46
+ export declare function skillFolders(tools: ToolId[]): string[];
47
+ export declare function planRender(root: string, config: PeerAiConfig): RenderPlan;
48
+ export interface RenderOptions {
49
+ cwd: string;
50
+ /** Change nothing; fail when a file is out of date. For CI. */
51
+ check: boolean;
52
+ /** Write only the skills, touching nothing that's committed. What each tool's setup step runs. */
53
+ skills?: boolean;
54
+ /** Print nothing unless something fails. */
55
+ quiet?: boolean;
56
+ }
57
+ /** Exit code 0 when done or up to date; 1 when a file was refused, or out of date with --check; 2 without a valid config. */
58
+ export declare function runRender(options: RenderOptions, out: Output): number;