fix: route security clusters out of clownfish
This commit is contained in:
parent
e399d1db44
commit
400f01657c
@ -16,6 +16,7 @@ Use this skill for ProjectClownfish operations in this repo. It is not just a on
|
||||
- Codex workers never mutate GitHub directly. They emit JSON; `scripts/apply-result.mjs` is the only mutation path.
|
||||
- Only the applicator may record `executed`. Worker output containing `executed` is a bug.
|
||||
- Closed historical refs are evidence only. They must not receive `close_*` actions.
|
||||
- Security-sensitive clusters do not belong in ProjectClownfish. Skip vulnerability, advisory, CVE/GHSA, leaked secret, credential/token/API-key, plaintext secret storage, SSRF/XSS/CSRF/RCE, security-class injection, exploitability, or sensitive-data exposure clusters and route them to central OpenClaw security handling.
|
||||
|
||||
## Recovery Check
|
||||
|
||||
@ -135,13 +136,15 @@ rg -o 'ghcrawl-[0-9]+' jobs/openclaw -g '*.md' |
|
||||
Pick the largest active clusters not already imported, then generate autonomous job files:
|
||||
|
||||
```bash
|
||||
node scripts/import-ghcrawl-clusters.mjs ID1 ID2 ID3 \
|
||||
node scripts/import-ghcrawl-clusters.mjs --from-ghcrawl --limit 40 \
|
||||
--repo openclaw/openclaw \
|
||||
--mode autonomous \
|
||||
--suffix autonomous-smoke \
|
||||
--allow-instant-close
|
||||
```
|
||||
|
||||
The importer skips existing ghcrawl IDs and security-sensitive clusters by default. Use explicit IDs only when you have inspected them first; do not pass `--include-security`.
|
||||
|
||||
Validate before committing:
|
||||
|
||||
```bash
|
||||
|
||||
11
README.md
11
README.md
@ -22,6 +22,13 @@ anything with active maintainer signal.
|
||||
|
||||
Everything else stays open or is escalated for maintainer review.
|
||||
|
||||
Security-sensitive clusters are deliberately out of scope. Anything that smells
|
||||
like a vulnerability, advisory, leaked secret, credential/token exposure,
|
||||
plaintext secret storage, SSRF/XSS/CSRF/RCE, security-class injection, or sensitive-data
|
||||
exposure is skipped at import time and routed to central OpenClaw security
|
||||
handling. ProjectClownfish is a backlog cleanup tool, not a security triage
|
||||
queue.
|
||||
|
||||
## Status
|
||||
|
||||
ProjectClownfish is intentionally smaller than ClawSweeper. ClawSweeper scans the whole OpenClaw backlog on a cadence; ProjectClownfish handles targeted clusters that were already grouped by a human, ghcrawl, or another dedupe tool.
|
||||
@ -116,6 +123,10 @@ npm run build-fix-artifact -- jobs/openclaw/autonomous-example.md --offline
|
||||
# Stage low-signal PR sweep jobs from local ghcrawl data.
|
||||
npm run import-low-signal -- --limit 20 --batch-size 5 --mode autonomous --sort stale
|
||||
|
||||
# Stage the next largest active ghcrawl clusters, skipping already-imported and
|
||||
# security-sensitive clusters by default.
|
||||
npm run import-ghcrawl -- --from-ghcrawl --limit 40 --mode autonomous --suffix autonomous-smoke --allow-instant-close
|
||||
|
||||
# Find failed cluster jobs that have not been superseded by a later success.
|
||||
npm run self-heal
|
||||
|
||||
|
||||
@ -3,24 +3,37 @@
|
||||
## Batch Flow
|
||||
|
||||
1. Create or export cluster job markdown files under `jobs/<repo>/`.
|
||||
2. Run local validation:
|
||||
2. Exclude security-sensitive clusters before staging. ProjectClownfish does not handle vulnerability, advisory, CVE/GHSA, leaked secret, credential/token exposure, plaintext secret storage, exploitability, security-class injection, SSRF/XSS/CSRF/RCE, or sensitive-data exposure work.
|
||||
3. Run local validation:
|
||||
|
||||
```bash
|
||||
npm run validate
|
||||
```
|
||||
|
||||
3. Dispatch plan jobs:
|
||||
4. Dispatch plan jobs:
|
||||
|
||||
```bash
|
||||
npm run dispatch -- jobs/openclaw/cluster-001.md jobs/openclaw/cluster-002.md --mode plan
|
||||
```
|
||||
|
||||
4. Review artifacts from GitHub Actions.
|
||||
5. Require `npm run review-results -- <artifact-dir>` to pass before promotion.
|
||||
6. Change selected jobs to `mode: execute` or `mode: autonomous`.
|
||||
7. Set repo variable `CLOWNFISH_ALLOW_EXECUTE=1` only for the execution window.
|
||||
8. Dispatch execute/autonomous jobs for reviewed clusters only. Workers still return JSON; `apply-result` performs safe GitHub mutations afterward.
|
||||
9. Reset `CLOWNFISH_ALLOW_EXECUTE=0`.
|
||||
5. Review artifacts from GitHub Actions.
|
||||
6. Require `npm run review-results -- <artifact-dir>` to pass before promotion.
|
||||
7. Change selected jobs to `mode: execute` or `mode: autonomous`.
|
||||
8. Set repo variable `CLOWNFISH_ALLOW_EXECUTE=1` only for the execution window.
|
||||
9. Dispatch execute/autonomous jobs for reviewed clusters only. Workers still return JSON; `apply-result` performs safe GitHub mutations afterward.
|
||||
10. Reset `CLOWNFISH_ALLOW_EXECUTE=0`.
|
||||
|
||||
## Security Boundary
|
||||
|
||||
Security-sensitive work is centrally managed outside ProjectClownfish. The importer skips those clusters by default, the job schema rejects `security_sensitive: true`, the planner marks any hydrated security-sensitive item, `review-results` fails mutating recommendations against those items, and `apply-result` blocks live targets with security-sensitive labels/title/body.
|
||||
|
||||
Use the central OpenClaw security path for:
|
||||
|
||||
- vulnerability reports, advisories, CVEs, GHSAs, exploitability, or security-class injection bugs;
|
||||
- leaked secrets, credentials, tokens, API keys, private keys, plaintext secret storage, or sensitive-data exposure;
|
||||
- SSRF, XSS, CSRF, RCE, auth-token leakage, or similar security-class bugs.
|
||||
|
||||
This boundary is intentionally conservative. If a cluster is borderline, do not stage it here.
|
||||
|
||||
## Auto-Closure
|
||||
|
||||
@ -36,6 +49,7 @@ It only applies closure actions when all of these are true:
|
||||
- the action includes a canonical/candidate fix ref and live `target_updated_at`;
|
||||
- GitHub still reports the same `updated_at`;
|
||||
- the target is open and not maintainer-authored.
|
||||
- the target is not security-sensitive.
|
||||
|
||||
The applicator writes an idempotency marker into the close comment before closing. Re-runs skip already-applied comments/closures instead of posting twice.
|
||||
|
||||
|
||||
@ -22,6 +22,8 @@ Evidence order:
|
||||
|
||||
Do not close based on title similarity alone.
|
||||
|
||||
Security-sensitive reports and PRs are not dedupe-cleanup work. If any item looks like a vulnerability, advisory, CVE/GHSA, leaked secret, credential, token, API key, plaintext secret storage, exploitability, security-class injection, SSRF/XSS/CSRF/RCE, or sensitive data exposure, route it to central OpenClaw security handling and do not recommend ProjectClownfish mutation.
|
||||
|
||||
Do not use `needs_human` as a synonym for "not closable." If an item is clearly
|
||||
related, independent, already closed, or a plausible follow-up fix, emit
|
||||
`keep_related`, `keep_independent`, `keep_closed`, or `fix_needed` with evidence.
|
||||
|
||||
@ -95,6 +95,8 @@ needed.
|
||||
|
||||
- Security reports or security-sensitive code that should go through the
|
||||
security triage path.
|
||||
- Security-sensitive PRs are not low-signal cleanup. Route them to central
|
||||
OpenClaw security handling instead of ProjectClownfish.
|
||||
- A green PR with a focused bug fix and clear reproduction.
|
||||
- A PR with recent maintainer review, assignment, or active author follow-up.
|
||||
- A unique bug report with reproduction detail, even if noisy.
|
||||
|
||||
14
instructions/security-boundary.md
Normal file
14
instructions/security-boundary.md
Normal file
@ -0,0 +1,14 @@
|
||||
# Security Boundary
|
||||
|
||||
ProjectClownfish does not triage security-sensitive clusters.
|
||||
|
||||
Security-sensitive means any issue, PR, title, body, label, comment, or changed-file context that appears to involve vulnerabilities, advisories, CVEs, GHSAs, exploitability, SSRF/XSS/CSRF/RCE, security-class injection, leaked secrets, credentials, tokens, API keys, private keys, plaintext credential storage, or exposure of sensitive data.
|
||||
|
||||
When security-sensitive evidence appears:
|
||||
|
||||
- do not close, merge, label, comment, open a fix PR, or recommend broad cleanup;
|
||||
- do not summarize exploit details beyond the minimum needed to say it is out of scope;
|
||||
- return `needs_human` with the exact boundary reason;
|
||||
- route the item to central OpenClaw security handling instead of ProjectClownfish.
|
||||
|
||||
This boundary is intentionally conservative. False positives are cheaper than accidentally routing security work through backlog-cleanup automation.
|
||||
@ -6,6 +6,7 @@ Scope:
|
||||
|
||||
- Start only from refs in the job file and refs linked from those item bodies, comments, review threads, closing refs, commits, or PR descriptions.
|
||||
- Do not run broad GitHub search unless the job explicitly says so.
|
||||
- If any hydrated item is security-sensitive, stop ProjectClownfish handling for that item or cluster and route to central OpenClaw security triage. Do not emit mutating actions.
|
||||
- Use the provided cluster preflight artifact and fix artifact as your starting inventory. It should include hydrated issue comments, PR review summaries, inline PR review comments, check state, merge state, touched files, and linked refs.
|
||||
- Treat closed context refs as evidence, not targets. Do not emit close actions for them.
|
||||
- If the cluster changed materially since preflight, block only the affected mutation. Keep classifying other items when the artifact is still current enough for non-mutating decisions.
|
||||
|
||||
@ -4,6 +4,8 @@ Execute mode still returns structured JSON first. Do not mutate GitHub directly.
|
||||
|
||||
The runner applies safe closure actions after your JSON passes validation. Your job is to classify the cluster and emit auditable actions that the deterministic GitHub applicator can replay.
|
||||
|
||||
Security-sensitive clusters are out of scope. If a title, body, label, review, comment, or file context suggests vulnerability, advisory, CVE/GHSA, leaked secret, credential, token, API key, plaintext secret storage, exploitability, security-class injection, SSRF/XSS/CSRF/RCE, or sensitive data exposure, return `needs_human` and do not emit close, merge, label, comment, or fix actions.
|
||||
|
||||
For each target action, include:
|
||||
|
||||
- `target`: issue/PR ref like `#123`
|
||||
|
||||
@ -12,6 +12,12 @@ reserve `needs_human` for the specific unresolved decision.
|
||||
|
||||
Evidence must come from GitHub issue/PR data, GitHub PR checks/diffs, or the job file. Do not cite external websites or mirrors.
|
||||
|
||||
Security-sensitive clusters are read-only and out of scope for ProjectClownfish.
|
||||
If any item appears related to vulnerabilities, advisories, CVEs/GHSAs, leaked
|
||||
secrets, credentials, tokens, API keys, plaintext secret storage, exploitability,
|
||||
security-class injection, SSRF/XSS/CSRF/RCE, or sensitive data exposure, route it to central
|
||||
OpenClaw security triage with `needs_human` and no mutating recommendation.
|
||||
|
||||
For each item, decide one action:
|
||||
|
||||
- keep canonical
|
||||
|
||||
@ -70,6 +70,14 @@
|
||||
"allow_post_merge_close": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"security_policy": {
|
||||
"type": "string",
|
||||
"enum": ["central_security_only"]
|
||||
},
|
||||
"security_sensitive": {
|
||||
"type": "boolean",
|
||||
"const": false
|
||||
},
|
||||
"canonical_hint": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@ -3,7 +3,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { assertAllowedOwner, parseArgs, parseJob, repoRoot, validateJob } from "./lib.mjs";
|
||||
import { assertAllowedOwner, hasSecuritySignalText, parseArgs, parseJob, repoRoot, validateJob } from "./lib.mjs";
|
||||
|
||||
const MAINTAINER_AUTHOR_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
||||
const CLOSE_ACTIONS = new Set([
|
||||
@ -180,6 +180,14 @@ function applyAction({ job, result, action, dryRun, allowMissingUpdatedAt }) {
|
||||
const live = fetchIssue(result.repo, target);
|
||||
const kind = live.pull_request ? "pull_request" : "issue";
|
||||
const authorAssociation = normalizeAuthorAssociation(live.author_association);
|
||||
if (hasSecuritySignal(live)) {
|
||||
return {
|
||||
...base,
|
||||
status: "blocked",
|
||||
reason: "security-sensitive target requires central security triage",
|
||||
live_state: live.state,
|
||||
};
|
||||
}
|
||||
if (MAINTAINER_AUTHOR_ASSOCIATIONS.has(authorAssociation)) {
|
||||
return {
|
||||
...base,
|
||||
@ -396,10 +404,7 @@ function validateLowSignalLiveState(repo, target, live, kind) {
|
||||
}
|
||||
|
||||
function hasSecuritySignal(issue) {
|
||||
const text = [issue.title, issue.body, ...(issue.labels ?? []).map((label) => label.name ?? label)]
|
||||
.join("\n")
|
||||
.toLowerCase();
|
||||
return /\b(security|vulnerability|secret|credential|cve|ghsa)\b/.test(text);
|
||||
return hasSecuritySignalText(issue.title, issue.body, issue.labels ?? []);
|
||||
}
|
||||
|
||||
function fetchIssue(repo, number) {
|
||||
|
||||
@ -3,19 +3,28 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { parseArgs, repoRoot } from "./lib.mjs";
|
||||
import { hasSecuritySignalText, parseArgs, repoRoot } from "./lib.mjs";
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const clusterIds = args._.map((value) => Number(value)).filter(Boolean);
|
||||
const repo = String(args.repo ?? "openclaw/openclaw");
|
||||
const dbPath = path.resolve(String(args.db ?? path.join(os.homedir(), ".config", "ghcrawl", "ghcrawl.db")));
|
||||
const outDir = path.resolve(String(args.out ?? path.join(repoRoot(), "jobs", repo.split("/")[0])));
|
||||
const mode = String(args.mode ?? "plan");
|
||||
const suffix = typeof args.suffix === "string" ? args.suffix : "";
|
||||
const allowInstantClose = Boolean(args["allow-instant-close"]);
|
||||
const skipExisting = args["skip-existing"] !== "false";
|
||||
const skipSecurity = args["include-security"] !== true && args["skip-security"] !== "false";
|
||||
const fromGhcrawl = Boolean(args["from-ghcrawl"] || args.all);
|
||||
const limit = numberArg("limit", 40);
|
||||
const minSize = numberArg("min-size", 2);
|
||||
let clusterIds = args._.map((value) => Number(value)).filter(Boolean);
|
||||
|
||||
if (clusterIds.length === 0 && fromGhcrawl) {
|
||||
clusterIds = selectClusterIds();
|
||||
}
|
||||
|
||||
if (clusterIds.length === 0) {
|
||||
console.error("usage: node scripts/import-ghcrawl-clusters.mjs <cluster-id> [...] [--repo owner/repo] [--db path] [--out dir] [--mode plan|autonomous] [--suffix name] [--allow-instant-close]");
|
||||
console.error("usage: node scripts/import-ghcrawl-clusters.mjs <cluster-id> [...] [--from-ghcrawl] [--limit N] [--repo owner/repo] [--db path] [--out dir] [--mode plan|autonomous] [--suffix name] [--allow-instant-close]");
|
||||
process.exit(2);
|
||||
}
|
||||
if (!["plan", "execute", "autonomous"].includes(mode)) {
|
||||
@ -25,7 +34,14 @@ if (!["plan", "execute", "autonomous"].includes(mode)) {
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const existingClusterIds = skipExisting ? existingGhcrawlClusterIds(outDir) : new Set();
|
||||
|
||||
for (const clusterId of clusterIds) {
|
||||
if (existingClusterIds.has(clusterId)) {
|
||||
console.error(`skip existing cluster: ${clusterId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const members = sqliteJson(`
|
||||
select
|
||||
c.id as cluster_id,
|
||||
@ -41,6 +57,8 @@ for (const clusterId of clusterIds) {
|
||||
t.kind,
|
||||
t.state,
|
||||
t.title,
|
||||
t.body,
|
||||
t.labels_json,
|
||||
t.updated_at
|
||||
from clusters c
|
||||
join cluster_members cm on cm.cluster_id = c.id
|
||||
@ -55,6 +73,14 @@ for (const clusterId of clusterIds) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const securitySensitive = members.some((member) =>
|
||||
hasSecuritySignalText(member.title, member.body, safeJson(member.labels_json)),
|
||||
);
|
||||
if (securitySensitive && skipSecurity) {
|
||||
console.error(`skip security-sensitive cluster: ${clusterId} ${members[0].representative_title ?? ""}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const first = members[0];
|
||||
const representative = {
|
||||
number: first.representative_number,
|
||||
@ -88,6 +114,7 @@ for (const clusterId of clusterIds) {
|
||||
" - merge",
|
||||
" - fix",
|
||||
"require_human_for:",
|
||||
" - security_sensitive",
|
||||
" - failing_checks",
|
||||
" - conflicting_prs",
|
||||
" - unclear_canonical",
|
||||
@ -98,6 +125,8 @@ for (const clusterId of clusterIds) {
|
||||
...yamlList(openMembers.map((member) => `#${member.number}`)),
|
||||
"cluster_refs:",
|
||||
...yamlList(members.map((member) => `#${member.number}`)),
|
||||
"security_policy: central_security_only",
|
||||
`security_sensitive: ${securitySensitive ? "true" : "false"}`,
|
||||
...(mode === "autonomous" || mode === "execute"
|
||||
? [
|
||||
`allow_instant_close: ${allowInstantClose ? "true" : "false"}`,
|
||||
@ -147,6 +176,19 @@ for (const clusterId of clusterIds) {
|
||||
console.log(path.relative(repoRoot(), filePath));
|
||||
}
|
||||
|
||||
function selectClusterIds() {
|
||||
return sqliteJson(`
|
||||
select
|
||||
c.id,
|
||||
c.member_count
|
||||
from clusters c
|
||||
where c.closed_at_local is null
|
||||
and c.member_count >= ${sqlNumber(minSize)}
|
||||
order by c.member_count desc, c.id asc
|
||||
limit ${sqlNumber(limit)}
|
||||
`).map((row) => Number(row.id)).filter(Boolean);
|
||||
}
|
||||
|
||||
function sqliteJson(sql) {
|
||||
const output = execFileSync("sqlite3", ["-json", dbPath, sql], {
|
||||
cwd: repoRoot(),
|
||||
@ -156,6 +198,12 @@ function sqliteJson(sql) {
|
||||
return JSON.parse(output || "[]");
|
||||
}
|
||||
|
||||
function numberArg(name, fallback) {
|
||||
const value = Number(args[name] ?? fallback);
|
||||
if (!Number.isInteger(value) || value < 1) throw new Error(`--${name} must be a positive integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function sqlNumber(value) {
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`unsafe cluster id: ${value}`);
|
||||
@ -163,6 +211,26 @@ function sqlNumber(value) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function safeJson(value) {
|
||||
try {
|
||||
return JSON.parse(value || "[]");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function existingGhcrawlClusterIds(dir) {
|
||||
if (!fs.existsSync(dir)) return new Set();
|
||||
const ids = new Set();
|
||||
for (const entry of fs.readdirSync(dir, { recursive: true })) {
|
||||
const file = path.join(dir, String(entry));
|
||||
if (!file.endsWith(".md") || !fs.statSync(file).isFile()) continue;
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
for (const match of text.matchAll(/\bghcrawl-(\d+)\b/g)) ids.add(Number(match[1]));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function yamlList(values) {
|
||||
if (values.length === 0) return [" []"];
|
||||
return values.map((value) => ` - ${quoteYaml(value)}`);
|
||||
|
||||
@ -3,7 +3,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { parseArgs, repoRoot } from "./lib.mjs";
|
||||
import { hasSecuritySignalText, parseArgs, repoRoot } from "./lib.mjs";
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const repo = String(args.repo ?? "openclaw/openclaw");
|
||||
@ -96,7 +96,7 @@ function scoreCandidate(row) {
|
||||
|
||||
if (isMaintainerAssociated(raw.author_association)) blockers.push(`author association is ${raw.author_association}`);
|
||||
if (assignees.length > 0) blockers.push("assigned PR");
|
||||
if (hasSecuritySignal(title, body, labels)) blockers.push("security-sensitive text or labels");
|
||||
if (hasSecuritySignalText(title, body, labels)) blockers.push("security-sensitive text or labels");
|
||||
|
||||
addSignal(signals, blankTemplateSignal(body), "blank_template");
|
||||
addSignal(signals, docsOnlySignal(title, files), "docs_only");
|
||||
@ -163,6 +163,8 @@ function writeJob(batch, index) {
|
||||
...yamlList(batch.map((candidate) => candidate.ref)),
|
||||
"cluster_refs:",
|
||||
...yamlList(batch.map((candidate) => candidate.ref)),
|
||||
"security_policy: central_security_only",
|
||||
"security_sensitive: false",
|
||||
"allow_instant_close: false",
|
||||
"allow_low_signal_pr_close: true",
|
||||
"allow_fix_pr: false",
|
||||
@ -285,11 +287,6 @@ function isTestPath(file) {
|
||||
return /(\.test\.|\.spec\.|__tests__|^test\/|^test-fixtures\/)/.test(file);
|
||||
}
|
||||
|
||||
function hasSecuritySignal(title, body, labels) {
|
||||
const text = [title, body, ...labels.map((label) => label.name ?? label)].join("\n").toLowerCase();
|
||||
return /\b(security|vulnerability|secret|credential|cve|ghsa)\b/.test(text);
|
||||
}
|
||||
|
||||
function isMaintainerAssociated(value) {
|
||||
return ["OWNER", "MEMBER", "COLLABORATOR"].includes(String(value ?? "").toUpperCase());
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const SECURITY_SIGNAL_PATTERN =
|
||||
/\b(vulnerabilit(?:y|ies)|cve-\d+|ghsa|advisory|exploit|ssrf|xss|csrf|rce|(?:sql|command|code|prompt)\s*injection|auth(?:entication)?\s*bypass|privilege\s+escalation|sensitive\s+data|security\s+(?:issue|bug|fix|patch|advisory|triage|review)|(?:secretref|secret|credential|api[-_\s]?key|private[-_\s]?key|token).{0,80}(?:leak(?:ed|age)?|expos(?:e|ed|ure)|plaintext|plain[-_\s]?text)|(?:leak(?:ed|age)?|expos(?:e|ed|ure)|plaintext|plain[-_\s]?text).{0,80}(?:secretref|secret|credential|api[-_\s]?key|private[-_\s]?key|token))\b/i;
|
||||
|
||||
export function repoRoot() {
|
||||
return path.resolve(import.meta.dirname, "..");
|
||||
}
|
||||
@ -122,16 +125,20 @@ export function validateJob(job) {
|
||||
"allow_fix_pr",
|
||||
"allow_merge",
|
||||
"allow_post_merge_close",
|
||||
"security_sensitive",
|
||||
]) {
|
||||
if (fm[key] !== undefined && typeof fm[key] !== "boolean") {
|
||||
errors.push(`${key} must be true or false`);
|
||||
}
|
||||
}
|
||||
for (const key of ["canonical_hint", "target_checkout", "triage_policy"]) {
|
||||
for (const key of ["canonical_hint", "target_checkout", "triage_policy", "security_policy"]) {
|
||||
if (fm[key] !== undefined && typeof fm[key] !== "string") {
|
||||
errors.push(`${key} must be a string`);
|
||||
}
|
||||
}
|
||||
if (fm.security_sensitive === true) {
|
||||
errors.push("security_sensitive jobs are out of scope for ProjectClownfish; route them to central security triage");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@ -159,6 +166,8 @@ export function renderPrompt(job, requestedMode, context = {}) {
|
||||
const parts = [
|
||||
readText("prompts/worker-system.md"),
|
||||
readText(modePrompt),
|
||||
"## Security boundary",
|
||||
readText("instructions/security-boundary.md"),
|
||||
"## Dedupe policy",
|
||||
readText("instructions/dedupe.md"),
|
||||
"## Closure policy",
|
||||
@ -191,6 +200,19 @@ export function renderPrompt(job, requestedMode, context = {}) {
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
export function hasSecuritySignalText(...values) {
|
||||
const text = values.flatMap(flattenSecurityText).join("\n");
|
||||
return SECURITY_SIGNAL_PATTERN.test(text);
|
||||
}
|
||||
|
||||
function flattenSecurityText(value) {
|
||||
if (Array.isArray(value)) return value.flatMap(flattenSecurityText);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.values(value).flatMap(flattenSecurityText);
|
||||
}
|
||||
return [String(value ?? "")];
|
||||
}
|
||||
|
||||
function isGithubRef(value) {
|
||||
const text = String(value ?? "");
|
||||
return /^#?[0-9]+$/.test(text) || /^https:\/\/github\.com\/[^/]+\/[^/]+\/(?:issues|pull)\/[0-9]+/.test(text);
|
||||
|
||||
@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
assertAllowedOwner,
|
||||
hasSecuritySignalText,
|
||||
makeRunDir,
|
||||
parseArgs,
|
||||
parseJob,
|
||||
@ -89,6 +90,7 @@ while (pending.length > 0) {
|
||||
}
|
||||
|
||||
const itemList = [...items.values()].sort((left, right) => left.number - right.number);
|
||||
const securitySensitiveItems = itemList.filter((item) => itemSecuritySensitive(item));
|
||||
const plan = {
|
||||
repo: job.frontmatter.repo,
|
||||
cluster_id: job.frontmatter.cluster_id,
|
||||
@ -98,6 +100,13 @@ const plan = {
|
||||
generated_at: new Date().toISOString(),
|
||||
offline,
|
||||
main: branch,
|
||||
security_boundary: {
|
||||
policy: job.frontmatter.security_policy ?? "central_security_only",
|
||||
security_sensitive_items: securitySensitiveItems.map((item) => item.ref),
|
||||
action: securitySensitiveItems.length > 0
|
||||
? "No ProjectClownfish mutation is allowed; route to central OpenClaw security handling."
|
||||
: "No security-sensitive signal detected in hydrated job refs.",
|
||||
},
|
||||
scope: {
|
||||
seed_refs: seedRefs.map(formatNormalizedRef),
|
||||
linked_refs: [...linkedRefs.values()].map(formatNormalizedRef).sort(),
|
||||
@ -117,6 +126,7 @@ const plan = {
|
||||
canonical_candidates: canonicalCandidates(itemList, job),
|
||||
safety_gates: [
|
||||
"re-fetch live state before every close/comment/label/merge/fix action",
|
||||
"security-sensitive clusters are out of scope and must route to central OpenClaw security handling",
|
||||
"closed context refs are evidence only; do not emit closure actions for already-closed refs",
|
||||
"stop with needs_human when canonical choice is unclear",
|
||||
"stop with needs_human when checks fail, conflicts exist, or cluster state changes",
|
||||
@ -247,6 +257,7 @@ function summarizeItem(item, job) {
|
||||
updated_at: item.updated_at,
|
||||
closed_at: item.closed_at,
|
||||
body_excerpt: item.body_excerpt,
|
||||
security_sensitive: itemSecuritySensitive(item),
|
||||
comments_count: item.comments_count ?? item.comments.length,
|
||||
comments_hydrated: item.comments.length,
|
||||
comments_truncated: Math.max(0, item.comments.length - MAX_COMMENTS_PER_ITEM),
|
||||
@ -320,6 +331,7 @@ function buildFixArtifact(plan, job) {
|
||||
kind: item.kind,
|
||||
state: item.state,
|
||||
updated_at: item.updated_at,
|
||||
security_sensitive: item.security_sensitive,
|
||||
hint: item.classification_hint,
|
||||
})),
|
||||
drive_plan: {
|
||||
@ -345,6 +357,7 @@ function buildFixArtifact(plan, job) {
|
||||
: "Post-merge closure disabled by job frontmatter.",
|
||||
},
|
||||
required_validation: [
|
||||
"stop and route security-sensitive clusters to central OpenClaw security handling",
|
||||
"prove current main behavior before fix, merge, fixed-by-candidate, or post-merge closeout actions",
|
||||
"for pure issue-dedupe closeout, prove the canonical issue and duplicate targets are live and current",
|
||||
"hydrate every provided and linked item before classification",
|
||||
@ -373,6 +386,7 @@ function canonicalCandidates(items, job) {
|
||||
}
|
||||
|
||||
function classificationHint(item, job) {
|
||||
if (itemSecuritySensitive(item)) return "security_sensitive_central_triage";
|
||||
const canonicalNumbers = new Set((job.frontmatter.canonical ?? []).map((ref) => normalizeRef(job.frontmatter.repo, ref).number));
|
||||
if (canonicalNumbers.has(item.number)) return "canonical_hint";
|
||||
if (item.state !== "open") return "already_closed";
|
||||
@ -384,6 +398,16 @@ function classificationHint(item, job) {
|
||||
return "open_issue_candidate";
|
||||
}
|
||||
|
||||
function itemSecuritySensitive(item) {
|
||||
return hasSecuritySignalText(
|
||||
item.title,
|
||||
item.body,
|
||||
item.labels,
|
||||
item.comments.map((comment) => comment.body),
|
||||
item.pull_request?.files?.map((file) => file.filename),
|
||||
);
|
||||
}
|
||||
|
||||
function extractLinkedRefs(defaultRepo, item) {
|
||||
const texts = [
|
||||
item.title,
|
||||
|
||||
@ -107,6 +107,9 @@ function reviewResult(resultPath) {
|
||||
if (evidenceHasExternalUrl(action.evidence ?? [])) {
|
||||
failures.push(`${target} evidence contains non-GitHub external URL`);
|
||||
}
|
||||
if (item?.security_sensitive && MUTATING_ACTIONS.has(name)) {
|
||||
failures.push(`${target} mutating action targets security-sensitive item`);
|
||||
}
|
||||
|
||||
if (action.status === "executed") {
|
||||
failures.push(`${target} action status must not be executed; only the applicator records execution`);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user