Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/reporter/cta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Pro conversion CTA shown at the foot of human-facing reports (terminal,
* markdown, and — via renderMarkdownReport — the GitHub Action job summary).
*
* Deliberately NOT added to JSON or SARIF output: those are machine-consumed and
* must stay clean. The CTA leads with the privacy + low-noise wedge (the free,
* local-first, zero-account scanner stays the moat) and points at the real
* ECC Tools GitHub App. Suppressible via env so CI logs and scripted use can
* silence it.
*/

const OPT_OUT_ENV_VARS = ["ECC_NO_CTA", "AGENTSHIELD_NO_CTA"] as const;

const PRO_URL = "https://github.com/apps/ecc-tools";

export const PRO_CTA_PLAIN =
"Scans run locally; nothing leaves your machine. " +
`Track fleet posture and drift over time with ECC Tools Pro: ${PRO_URL}`;

export const PRO_CTA_MARKDOWN =
"_Scans run locally; nothing leaves your machine. " +
`Track fleet posture and drift over time with [ECC Tools Pro](${PRO_URL})._`;

/**
* True when the operator has opted out of the CTA via an env var. Any non-empty
* value other than "0" / "false" counts as opt-out.
*/
export function ctaSuppressed(env: NodeJS.ProcessEnv = process.env): boolean {
return OPT_OUT_ENV_VARS.some((key) => {
const value = env[key];
return (
value !== undefined &&
value !== "" &&
value !== "0" &&
value.toLowerCase() !== "false"
);
});
}

/** Plain-text CTA footer lines, or [] when suppressed. */
export function proCtaPlainLines(env: NodeJS.ProcessEnv = process.env): ReadonlyArray<string> {
return ctaSuppressed(env) ? [] : [PRO_CTA_PLAIN];
}

/** Markdown CTA footer lines, or [] when suppressed. */
export function proCtaMarkdownLines(env: NodeJS.ProcessEnv = process.env): ReadonlyArray<string> {
return ctaSuppressed(env) ? [] : ["---", "", PRO_CTA_MARKDOWN];
}
7 changes: 7 additions & 0 deletions src/reporter/json.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { SecurityReport } from "../types.js";
import { proCtaMarkdownLines } from "./cta.js";

function formatRuntimeConfidence(value: string): string {
switch (value) {
Expand Down Expand Up @@ -155,5 +156,11 @@ export function renderMarkdownReport(report: SecurityReport): string {
lines.push("No security issues were detected in the scanned configuration.");
}

const cta = proCtaMarkdownLines();
if (cta.length > 0) {
lines.push("");
lines.push(...cta);
}

return lines.join("\n");
}
4 changes: 4 additions & 0 deletions src/reporter/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
CorpusValidationResult,
DeepScanResult,
} from "../types.js";
import { proCtaPlainLines } from "./cta.js";

/**
* Render a security report to the terminal with colors and formatting.
Expand Down Expand Up @@ -125,6 +126,9 @@ export function renderTerminalReport(report: SecurityReport): string {
// Footer
lines.push(chalk.dim(" ─────────────────────────────────────────"));
lines.push(chalk.dim(" AgentShield — Security auditor for AI agent configs"));
for (const cta of proCtaPlainLines()) {
lines.push(chalk.dim(` ${cta}`));
}
lines.push("");

return lines.join("\n");
Expand Down
35 changes: 35 additions & 0 deletions tests/reporter/cta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import {
ctaSuppressed,
proCtaPlainLines,
proCtaMarkdownLines,
PRO_CTA_PLAIN,
PRO_CTA_MARKDOWN,
} from "../../src/reporter/cta.js";

describe("pro CTA", () => {
it("leads with the privacy wedge and points at the real ECC Tools app", () => {
expect(PRO_CTA_PLAIN).toMatch(/locally/i);
expect(PRO_CTA_PLAIN).toContain("https://github.com/apps/ecc-tools");
expect(PRO_CTA_MARKDOWN).toContain("[ECC Tools Pro](https://github.com/apps/ecc-tools)");
});

it("is shown by default", () => {
expect(ctaSuppressed({})).toBe(false);
expect(proCtaPlainLines({})).toEqual([PRO_CTA_PLAIN]);
expect(proCtaMarkdownLines({})).toContain(PRO_CTA_MARKDOWN);
});

it("is suppressed by ECC_NO_CTA / AGENTSHIELD_NO_CTA", () => {
expect(ctaSuppressed({ ECC_NO_CTA: "1" })).toBe(true);
expect(ctaSuppressed({ AGENTSHIELD_NO_CTA: "true" })).toBe(true);
expect(proCtaPlainLines({ ECC_NO_CTA: "1" })).toEqual([]);
expect(proCtaMarkdownLines({ AGENTSHIELD_NO_CTA: "yes" })).toEqual([]);
});

it("treats empty / 0 / false as not-suppressed", () => {
expect(ctaSuppressed({ ECC_NO_CTA: "" })).toBe(false);
expect(ctaSuppressed({ ECC_NO_CTA: "0" })).toBe(false);
expect(ctaSuppressed({ AGENTSHIELD_NO_CTA: "false" })).toBe(false);
});
});
8 changes: 8 additions & 0 deletions tests/reporter/json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ describe("renderMarkdownReport", () => {
expect(output).toContain("# AgentShield Security Report");
});

it("appends the Pro CTA footer (human-facing markdown only)", () => {
const output = renderMarkdownReport(makeReport());
expect(output).toContain("ECC Tools Pro");
expect(output).toContain("https://github.com/apps/ecc-tools");
// JSON output stays machine-clean: no marketing string.
expect(renderJsonReport(makeReport())).not.toContain("ECC Tools Pro");
});
Comment on lines +159 to +165

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Integration test is environment-sensitive

renderMarkdownReport calls proCtaMarkdownLines() with no argument, so it reads from process.env directly. The assertion expect(output).toContain("ECC Tools Pro") will therefore fail whenever ECC_NO_CTA=1 or AGENTSHIELD_NO_CTA=1 is present in the test environment — which is exactly what the PR's own documented opt-out guidance tells CI users to set. A developer or CI pipeline that follows the suppression guidance and then runs npm test will see this test break.

The unit tests in cta.test.ts correctly inject a controlled env object, but this integration test bypasses that isolation. The fix is to either use vi.stubEnv / vi.unstubAllEnvs around this test, or delete the two env vars from process.env for the duration of the test.


it("includes grade and score", () => {
const output = renderMarkdownReport(makeReport());
expect(output).toContain("**Grade:** B (80/100)");
Expand Down
Loading