Skip to content
Merged
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
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,45 @@ jobs:
Up to **50 targets** per run. Over the cap the API refuses rather than
truncating — a clean summary over a silent subset would be believed.

## Team policy — `.sato/policy.json` (v1.2.0)

Commit a `.sato/policy.json` to the repository being checked and the diff mode
above also evaluates each changed dependency's Sato Check readings against it.
The job fails **only when a reading matches a rule you switched on**; every match
is listed in the job summary and the PR comment with the rule, what the reading
says and the evidence rule id behind it.

```json
{
"version": 1,
"fail_on": ["key_egress_observed", "undeclared_key_read", "unlimited_fund_action", "install_script_added", "new_host"],
"allow_hosts": ["*.base.org", "api.coingecko.com"],
"allow_subjects": ["npm:@acme/wallet"]
}
```

| rule | matches when |
|---|---|
| `key_egress_observed` | a planted test key was seen leaving during one of Sato Hub's runs |
| `undeclared_key_read` | code reads key material and the setup does not ask for a key |
| `unlimited_fund_action` | a fund-moving action has no configurable limit found |
| `install_script_added` | this version adds an install script |
| `new_host` | this version adds a request to a host not in `allow_hosts` (exact host or `*.suffix`) |

`allow_subjects` exempts a subject id from every rule. `fail_on` defaults to
`["key_egress_observed"]` when omitted.

- **`unknown` never matches a rule.** No reading is not a finding.
- The install check returns summaries, so the action reads each subject's full
profile from `GET /api/check?target=<id>` (four at a time, 30 s timeout). A read
that fails is reported as *could not evaluate* — never a match.
- An invalid policy file is reported and ignored; it never fails the build.
- No policy file → the v1.1 behaviour: only `fail_on` (input) decides.
- New output: `policy_violations` (count of matches).

A match names a fact your team asked to be stopped for. It is not a rating of
the dependency.

## Outputs

`verdict` (the most severe seen) · `go` · `caution` · `no` · `unknown` ·
Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ outputs:
key_egress_observed:
description: "true when Sato Check reports a planted key seen leaving for a changed dependency."
value: ${{ steps.run.outputs.key_egress_observed }}
policy_violations:
description: "Number of readings that matched a rule in the repository's .sato/policy.json (0 without a policy file)."
value: ${{ steps.run.outputs.policy_violations }}

runs:
using: "composite"
Expand Down
177 changes: 174 additions & 3 deletions src/custody.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
import { execFileSync } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";

export const UA = "satohub-preflight-action/1.1";
// SATO_CHECK_UA lets Sato Hub's own CI identify itself, so it is not counted as outside use.
export const UA = process.env.SATO_CHECK_UA || "satohub-preflight-action/1.2";
export const POLICY_PATH = ".sato/policy.json";
export const PROFILE_CONCURRENCY = 4;
export const TIMEOUT_MS = 30_000;
export const MARKER = "<!-- sato-check:preflight-action -->";
export const MAX_COMMAND_BYTES = 15_000;
Expand Down Expand Up @@ -128,7 +131,7 @@ export function subjectName(sub) {
}

/** Markdown for the job summary and the PR comment. */
export function renderReport(res, { changes = [], api = "https://satohub.ai", error = null, localSkills = 0 } = {}) {
export function renderReport(res, { changes = [], api = "https://satohub.ai", error = null, localSkills = 0, policy = null } = {}) {
const base = api.replace(/\/$/, "");
const out = [MARKER, "## Sato Check — dependencies changed in this PR", ""];
if (changes.length) {
Expand Down Expand Up @@ -158,6 +161,7 @@ export function renderReport(res, { changes = [], api = "https://satohub.ai", er
out.push(`_Not checked: ${res.unresolved.map((u) => `\`${cell(u.input)}\` (${cell(u.reason)})`).join(", ")}_`, "");
}
}
out.push(...renderPolicy(policy));
if (localSkills) out.push(`_${localSkills} skill file(s) in this repo changed. A local SKILL.md is not a registry install, so Sato Check has no record to read for it — review it in the diff._`, "");
out.push(`> ${DISCLAIMER}`, "", `[Sato Check](${base}/check)`);
return out.join("\n");
Expand All @@ -177,6 +181,160 @@ export function shouldFail(res, failOnList) {
return failOnList.includes("key_egress_observed") && res?.has_observed_key_egress === true;
}


// ---------- team policy (.sato/policy.json, v1.2) ----------
// Mirrors lib/custody/policy.ts on Sato Hub (the action cannot import the app).
// A policy names which custody FACTS a team wants a build stopped for. A match
// is reported by the rule that matched and its evidence; `unknown` never matches.

export const POLICY_RULES = ["key_egress_observed", "undeclared_key_read", "unlimited_fund_action", "install_script_added", "new_host"];
export const RULE_TEXT = {
key_egress_observed: "a planted test key was seen leaving during a run",
undeclared_key_read: "code reads key material and the setup does not ask for a key",
unlimited_fund_action: "a fund-moving action with no configurable limit found",
install_script_added: "this version adds an install script",
new_host: "this version adds a request to a host not in allow_hosts",
};

const strList = (v, name) => {
if (v == null) return [];
if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) return { error: `\`${name}\` must be an array of strings.` };
return v.map((s) => s.trim()).filter(Boolean).slice(0, 500);
};

/** Validate a policy object. Returns {ok, policy} | {ok:false, error}. */
export function parsePolicy(input) {
if (input == null || typeof input !== "object" || Array.isArray(input)) return { ok: false, error: "the policy must be a JSON object" };
if (input.version !== undefined && input.version !== 1) return { ok: false, error: "only policy `version: 1` is understood" };
const fail = input.fail_on === undefined ? ["key_egress_observed"] : strList(input.fail_on, "fail_on");
if (!Array.isArray(fail)) return { ok: false, error: fail.error };
for (const r of fail) if (!POLICY_RULES.includes(r)) return { ok: false, error: `unknown rule "${r}" (rules: ${POLICY_RULES.join(", ")})` };
const hosts = strList(input.allow_hosts, "allow_hosts");
if (!Array.isArray(hosts)) return { ok: false, error: hosts.error };
const subjects = strList(input.allow_subjects, "allow_subjects");
if (!Array.isArray(subjects)) return { ok: false, error: subjects.error };
return {
ok: true,
policy: {
version: 1,
fail_on: POLICY_RULES.filter((r) => fail.includes(r)),
...(hosts.length ? { allow_hosts: hosts.map((h) => h.toLowerCase()) } : {}),
...(subjects.length ? { allow_subjects: subjects } : {}),
},
};
}

/** Read .sato/policy.json. {policy:null} when absent; {policy:null, error} when unreadable. */
export function readPolicy(readFile = (p) => (existsSync(p) ? readFileSync(p, "utf8") : null), path = POLICY_PATH) {
let text;
try { text = readFile(path); } catch (e) { return { policy: null, error: `could not read ${path} (${e?.message || e})` }; }
if (text == null || text === "") return { policy: null };
let json;
try { json = JSON.parse(text); } catch { return { policy: null, error: `${path} is not valid JSON` }; }
const r = parsePolicy(json);
return r.ok ? { policy: r.policy } : { policy: null, error: `${path}: ${r.error}` };
}

function hostAllowed(host, allow) {
const h = String(host).toLowerCase();
return (allow || []).some((a) => (a.startsWith("*.") ? h === a.slice(2) || h.endsWith(a.slice(1)) : h === a));
}

/** Which of the policy's rules this profile's readings match. Same semantics as the app. */
export function evaluatePolicy(profile, policy) {
const on = new Set(policy.fail_on || []);
const v = [];
if (!profile || typeof profile !== "object") return { ok: true, violations: [] };
if ((policy.allow_subjects || []).includes(profile.subject?.id)) return { ok: true, violations: [] };
const evidence = Array.isArray(profile.evidence) ? profile.evidence : [];
if (on.has("key_egress_observed") && profile.key_egress === "observed") {
v.push({ rule: "key_egress_observed", detail: `A planted test key was sent to ${(profile.key_egress_hosts || []).join(", ") || "an outside host"}.`, evidence_rule: "O-canary-egress" });
}
if (on.has("undeclared_key_read") && profile.key_access === "reads" && !evidence.some((e) => e.rule === "D-env-key")) {
const read = evidence.find((e) => e.rule === "T-key-read");
v.push({ rule: "undeclared_key_read", detail: `Code reads key material and the setup does not ask for a key${read ? ` (${read.source})` : ""}.`, evidence_rule: read?.rule ?? "T-key-read" });
}
if (on.has("unlimited_fund_action") && Array.isArray(profile.fund_actions)) {
for (const a of profile.fund_actions.filter((a) => a.limit_configurable === false)) {
v.push({ rule: "unlimited_fund_action", detail: `${a.name} (${a.action}) — no configurable limit found.`, evidence_rule: a.evidence_class === "declared" ? "D-fund-tool" : "N-unlimited-fund-action" });
}
}
const items = Array.isArray(profile.changes?.items) ? profile.changes.items : [];
if (on.has("install_script_added")) {
for (const c of items.filter((c) => c.kind === "install_script_added")) v.push({ rule: "install_script_added", detail: c.detail, evidence_rule: "T-install-script" });
}
if (on.has("new_host")) {
for (const c of items.filter((c) => c.kind === "host_added")) {
const host = String(c.detail || "").replace(/^adds a request to /, "").trim();
if (hostAllowed(host, policy.allow_hosts)) continue;
const h = (profile.hosts || []).find((x) => x.host === host);
v.push({ rule: "new_host", detail: c.detail, evidence_rule: h?.evidence_class === "observed" ? "O-hosts" : "T-hosts" });
}
}
return { ok: v.length === 0, violations: v };
}

/** GET /api/check?target=<id> → the full profile. Throws on anything but a 200 with a profile. */
export async function fetchProfile(api, id, fetchFn = fetch) {
const res = await fetchFn(`${api.replace(/\/$/, "")}/api/check?target=${encodeURIComponent(id)}`, {
headers: { "user-agent": UA, accept: "application/json" },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const json = await res.json().catch(() => null);
if (res.status !== 200 || !json?.profile) throw new Error(json?.error || `HTTP ${res.status}`);
return json.profile;
}

/**
* Evaluate every subject the install check answered for against the policy.
* The install response carries summaries only, so each profile is read from
* GET /api/check (4 at a time). A read that fails is "could not evaluate" —
* never a match.
*/
export async function evaluateSubjects(subjects, policy, { api, fetchFn = fetch, concurrency = PROFILE_CONCURRENCY } = {}) {
const violations = [];
const unevaluated = [];
const list = (subjects || []).filter((s) => s?.subject?.id && !(policy.allow_subjects || []).includes(s.subject.id));
let next = 0;
const worker = async () => {
while (next < list.length) {
const s = list[next++];
try {
const profile = await fetchProfile(api, s.subject.id, fetchFn);
for (const v of evaluatePolicy(profile, policy).violations) violations.push({ subject: subjectName(s), id: s.subject.id, ...v });
} catch (e) {
unevaluated.push({ subject: subjectName(s), reason: e?.message || String(e) });
}
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, list.length) }, worker));
const order = (x) => list.findIndex((s) => s.subject.id === x.id);
violations.sort((a, b) => order(a) - order(b));
return { violations, unevaluated };
}

/** Markdown section for the policy outcome. */
export function renderPolicy(policyOut) {
if (!policyOut) return [];
const out = ["**Team policy (`.sato/policy.json`)**", ""];
if (policyOut.error) {
out.push(`_The policy file could not be used: ${cell(policyOut.error)}. No policy rule was applied; the build is not failed over it._`, "");
return out;
}
out.push(`Rules on: ${policyOut.policy.fail_on.map((r) => `\`${r}\``).join(", ") || "none"}.`, "");
if (policyOut.violations.length) {
out.push("| dependency | rule matched | what the reading says | evidence |", "|---|---|---|---|");
for (const v of policyOut.violations) out.push(`| \`${cell(v.subject)}\` | \`${v.rule}\` — ${RULE_TEXT[v.rule]} | ${cell(v.detail)} | \`${v.evidence_rule}\` |`);
out.push("");
} else {
out.push("_No reading matched a rule in the policy. An `unknown` answer never matches._", "");
}
if (policyOut.unevaluated.length) {
out.push(`_Could not evaluate (not a match): ${policyOut.unevaluated.map((u) => `\`${cell(u.subject)}\` (${cell(u.reason)})`).join(", ")}_`, "");
}
return out;
}

// ---------- I/O ----------

function git(args) {
Expand Down Expand Up @@ -269,7 +427,16 @@ export async function runCustody({ api, failOn = [], token = "", env = process.e
error = e?.message || String(e);
log(`::warning::Sato Check was unreachable or refused (${error}). Reported as unknown; not failing the build.`);
}
const report = renderReport(res, { changes, api, error, localSkills: found.skills.length });
let policyOut = null;
const pol = (io.readPolicy || readPolicy)();
if (pol.error) {
policyOut = { error: pol.error };
log(`::warning::Sato Check: ${pol.error}. No policy rule applied; not failing the build over it.`);
} else if (pol.policy) {
const ev = res ? await evaluateSubjects(res.subjects, pol.policy, { api, fetchFn }) : { violations: [], unevaluated: [] };
policyOut = { policy: pol.policy, ...ev };
}
const report = renderReport(res, { changes, api, error, localSkills: found.skills.length, policy: policyOut });
summary(report);

if (token) {
Expand All @@ -286,6 +453,10 @@ export async function runCustody({ api, failOn = [], token = "", env = process.e
}

const egress = res?.has_observed_key_egress === true;
if (policyOut?.violations?.length) {
for (const v of policyOut.violations) log(`::error::Sato Check policy: ${v.subject} matched \`${v.rule}\` (${RULE_TEXT[v.rule]}): ${v.detail}`);
return { code: 1, report, subjects: res?.subjects?.length ?? 0, egress, violations: policyOut.violations.length };
}
if (shouldFail(res, failOn)) {
log("::error::Sato Check: a planted key was seen leaving during a run for a dependency this PR adds or changes, and fail_on includes key_egress_observed. See the job summary for the host.");
return { code: 1, report, subjects: res?.subjects?.length ?? 0, egress };
Expand Down
1 change: 1 addition & 0 deletions src/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ async function main() {
if (custody.skipped) console.log(`Sato Check diff mode skipped: ${custody.skipped}.`);
setOutput("custody_subjects", custody.subjects ?? 0);
setOutput("key_egress_observed", custody.egress ? "true" : "false");
setOutput("policy_violations", custody.violations ?? 0);
return code || custody.code;
}

Expand Down
Loading
Loading