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
6 changes: 6 additions & 0 deletions docs/repo-health/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ says which file is missing.
| [`repo-health-config.md`](../../plugins/magpie-setup/templates/repo-health-config.md) | Per-skill switches: deprecated runner labels, zizmor rule classes, dependency managers, SPDX expression, flaky-test thresholds. | `dependency-audit`, `flaky-test-triage`, `license-compliance-audit`, `workflow-security-audit` |
| [`runtime-invocation.md`](../../plugins/magpie-setup/templates/runtime-invocation.md) | Build prerequisite, run-a-single-file recipe, stream-capture conventions, network/dependency handling. | `audit-finding-fix` |

**Optional.** Each has a documented fallback; absent, the skill still runs.

| File | What it carries | Read by |
|---|---|---|
| [`project.md`](../../plugins/magpie-setup/templates/project.md) | Project manifest. Identity, repositories, mailing lists, tools enabled, CVE tooling, GitHub project-board + issue-template field declarations. The single file every skill reads to resolve project-scoped references. | `dependency-license-audit`, `workflow-optimization` |

<!-- END generated: skill-config -->

<!-- BEGIN generated: companion-skills (tools/dev/check-companion-skills.py --fix) -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ argument-hint: "[--manager pip|npm|cargo|maven|gradle|trivy] [--policy asf|allow
capability: capability:triage
surface_hash: sha256:9723ef52fb16204e
license: Apache-2.0
measured_tokens: 3624
measured_tokens: 3739
---

<!-- SPDX-License-Identifier: Apache-2.0
Expand Down Expand Up @@ -137,7 +137,9 @@ is not installed, surface the installation recipe and stop.
**Golden rule 6 — read the policy from config.** Read the policy model,
`allowed_licenses`, and `forbidden_licenses` from
`<project-config>/repo-health-config.md → dependency_license_audit`.
Default to the `asf` policy when not configured.
When not configured, default to the `asf` policy only for a project whose
`<project-config>/project.md` declares `organization: ASF`; any other project
defaults to `allowlist` with the lists below. Say which default applied.

---

Expand Down Expand Up @@ -196,7 +198,11 @@ repo_health:
unknown_license_action: flag # or: ignore
```

When no config file exists, use the ASF policy defaults above.
When no config file exists, the default depends on the project's organization
(`<project-config>/project.md → organization`): `organization: ASF` gets
`policy: asf`; every other project, including one with no `project.md`, gets
`policy: allowlist` with the `allowed_licenses` / `forbidden_licenses` above.
The ASF category model is ASF policy, not a general licence rule.

### ASF three-category model (`policy: asf`)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ argument-hint: "--repo owner/name"
capability: capability:triage
surface_hash: sha256:bbfe83b236640f9e
license: Apache-2.0
measured_tokens: 3048
measured_tokens: 3094
---

<!-- SPDX-License-Identifier: Apache-2.0
Expand Down Expand Up @@ -184,7 +184,10 @@ Apply these while classifying:
Fork CI and self-hosted runners are experimental on the Infra page.
Recommend them only when the evidence shows heavy jobs, and carry the security caveats with them.
- **ASF-only checks.**
The GitHub Actions Policy, `.asf.yaml` and `creation_cap` checks apply only to repositories under the ASF; skip them elsewhere.
The GitHub Actions Policy, `.asf.yaml` and `creation_cap` checks apply only to repositories under the ASF.
Run them only when `<project-config>/project.md` declares `organization: ASF`,
or, with no project config, when the repository's owner is the `apache` GitHub organization;
otherwise skip them and say so in the report.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ ASF preamble or the `@apache.org` sender pattern.

| Adapter | Status | Inbound channel | Reference doc |
|---|---|---|---|
| `asf-security` | shipping | Mail from `security@apache.org` or a personal `@apache.org` address with the ASF forwarding preamble | [`tools/gmail/asf-relay.md`](../gmail/asf-relay.md) |
| `asf-security` | shipping | ASF projects only (`organization: ASF`): mail from `security@apache.org` or a personal `@apache.org` address with the ASF forwarding preamble | [`tools/gmail/asf-relay.md`](../gmail/asf-relay.md) |
| `huntr-relay` | placeholder | Mail from huntr.com's notification address with the huntr disclosure preamble | not implemented — contract slot only |
| `hackerone-relay` | placeholder | Mail from HackerOne's notification address with the HackerOne handoff preamble | not implemented — contract slot only |
| `ghsa-relay` | placeholder | Mail forwarded from a GHSA private report by the GitHub notification system | not implemented — contract slot only |
Expand Down
6 changes: 6 additions & 0 deletions plugins/magpie-security-tools/tools/gmail/asf-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@

# Gmail — ASF-security-relay drafting

> **Scope — `organization: ASF` · 🪶 ASF-specific.** The `asf-security` forwarder adapter:
> reports relayed by the ASF Security team (`security@apache.org`). It applies only to a
> project whose `<project-config>/project.md` declares `organization: ASF`, which is also
> where its `forwarders.enabled: [asf-security]` default comes from
> ([`organizations/ASF/`](https://github.com/apache/magpie/blob/main/organizations/ASF/organization.md)).

Some reports reach the project's security list via the ASF security
team — `security@apache.org` itself, or a personal `@apache.org`
address of an ASF security-team member — forwarding a report that
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def _approve_by_opt_in(entry: LLMEntry, opt_in: list[OptInEntry]) -> Verdict:
False,
f"opt-in entry {opt.name!r} matches but the 'Approved-by' "
f"sub-bullet is missing or still has placeholder text "
f"({opt.approved_by!r}); a real PMC member must sign off.",
f"({opt.approved_by!r}); a member of the project's governing body must sign off.",
)
return Verdict(entry, True, f"opt-in entry {opt.name!r} (data-residency + approved-by present)")
return Verdict(
Expand Down Expand Up @@ -304,7 +304,7 @@ def _approve_endpoint_by_opt_in(
False,
f"opt-in entry {opt.name!r} matches but the 'Approved-by' "
f"sub-bullet is missing or still has placeholder text "
f"({opt.approved_by!r}); a real PMC member must sign off.",
f"({opt.approved_by!r}); a member of the project's governing body must sign off.",
)
return Verdict(entry, True, f"opt-in entry {opt.name!r} (data-residency + approved-by present)")

Expand Down
4 changes: 3 additions & 1 deletion plugins/magpie-setup/skills/setup/verify.md
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ adopter opted into via
- `mcp__claude_ai_Gmail__list_drafts`
- `mcp__claude_ai_Gmail__list_labels`
- `mcp__gmail-plaintext__create_draft`
- and, only when `project.md` declares `organization: ASF` (they are ASF services):
- `mcp__ponymail__search_list`
- `mcp__ponymail__auth_status`
- `mcp__ponymail__get_thread`
Expand Down Expand Up @@ -715,7 +716,8 @@ The skill invokes the CLI once per settings file:
```bash
uv run --project <framework>/tools/permission-audit \
permission-audit audit <repo>/.claude/settings.local.json \
--families <comma-joined families from the lock>
--families <comma-joined families from the lock> \
--organization <organization from project.md>
```

The CLI emits structured JSON the skill folds into the verify
Expand Down
2 changes: 1 addition & 1 deletion tools/forwarder-relay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ ASF preamble or the `@apache.org` sender pattern.

| Adapter | Status | Inbound channel | Reference doc |
|---|---|---|---|
| `asf-security` | shipping | Mail from `security@apache.org` or a personal `@apache.org` address with the ASF forwarding preamble | [`tools/gmail/asf-relay.md`](../gmail/asf-relay.md) |
| `asf-security` | shipping | ASF projects only (`organization: ASF`): mail from `security@apache.org` or a personal `@apache.org` address with the ASF forwarding preamble | [`tools/gmail/asf-relay.md`](../gmail/asf-relay.md) |
| `huntr-relay` | placeholder | Mail from huntr.com's notification address with the huntr disclosure preamble | not implemented — contract slot only |
| `hackerone-relay` | placeholder | Mail from HackerOne's notification address with the HackerOne handoff preamble | not implemented — contract slot only |
| `ghsa-relay` | placeholder | Mail forwarded from a GHSA private report by the GitHub notification system | not implemented — contract slot only |
Expand Down
2 changes: 1 addition & 1 deletion tools/gmail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Used by the security-issue-import / sync / invalidate flows. See [`tool.md`](too
- **Runtime:** claude.ai Gmail MCP (`mcp__claude_ai_Gmail__*`) for search / read / draft; the preferred `oauth_curl` draft backend is Python 3.11+ run via `uv` (`tools/gmail/oauth-draft`) plus `curl`.
- **CLIs:** None beyond the runtime on the MCP path; `uv` + `curl` for the `oauth_curl` backend.
- **Credentials / auth:** claude.ai Gmail MCP authenticated. For `oauth_curl`, a Google OAuth refresh-token file (default `~/.config/apache-magpie/gmail-oauth.json`, overridable via `$GMAIL_OAUTH_CREDENTIALS` or `tools.gmail.oauth_credentials_path`) created once by `oauth-draft-setup`. Read + draft only — never sends.
- **Network:** Gmail API (`gmail.googleapis.com`); `lists.apache.org` for the adjacent PonyMail archive lookups.
- **Network:** Gmail API (`gmail.googleapis.com`); `lists.apache.org` for the adjacent PonyMail archive lookups (ASF projects only: [`ponymail-archive.md`](ponymail-archive.md) and [`asf-relay.md`](asf-relay.md) are ASF-specific).
- **Optional:** `google-auth-oauthlib` (pulled by `uv` for the one-time `oauth-draft-setup` consent flow only).

## Security and privacy
Expand Down
6 changes: 6 additions & 0 deletions tools/gmail/asf-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@

# Gmail — ASF-security-relay drafting

> **Scope — `organization: ASF` · 🪶 ASF-specific.** The `asf-security` forwarder adapter:
> reports relayed by the ASF Security team (`security@apache.org`). It applies only to a
> project whose `<project-config>/project.md` declares `organization: ASF`, which is also
> where its `forwarders.enabled: [asf-security]` default comes from
> ([`organizations/ASF/`](../../organizations/ASF/organization.md)).

Some reports reach the project's security list via the ASF security
team — `security@apache.org` itself, or a personal `@apache.org`
address of an ASF security-team member — forwarding a report that
Expand Down
5 changes: 5 additions & 0 deletions tools/gmail/ponymail-archive.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@

# Gmail — ASF PonyMail archive URL construction

> **Scope — `organization: ASF` · 🪶 ASF-specific.** The archive these URLs point at,
> `lists.apache.org`, holds the ASF's lists only: use this recipe only for a project whose
> `<project-config>/project.md` declares `organization: ASF`. The PonyMail tooling itself is
> [`tools/ponymail/`](../ponymail/README.md), also ASF-only.

The ASF hosts mailing-list archives at
[`lists.apache.org`](https://lists.apache.org/) (internally
**PonyMail**). The skills use it for two distinct lookups:
Expand Down
45 changes: 32 additions & 13 deletions tools/permission-audit/src/permission_audit/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,21 +100,34 @@
"mcp__claude_ai_Gmail__search_threads",
"mcp__claude_ai_Gmail__list_drafts",
"mcp__claude_ai_Gmail__list_labels",
"mcp__ponymail__search_list",
"mcp__ponymail__auth_status",
"mcp__ponymail__get_thread",
"mcp__ponymail__get_email",
"mcp__ponymail__list_restrictions",
"mcp__apache-projects__project_stats",
"mcp__apache-projects__get_committee",
"mcp__apache-projects__get_group_members",
"mcp__apache-projects__get_person",
"mcp__apache-projects__search_people",
"Bash(vulnogram-api-record-fetch *)",
}
),
}

# Organization → family → patterns recommended only to that organization's
# projects: the ASF's PonyMail archive, its project directory and its CVE
# tool mean nothing to a project outside the ASF, so recommending them
# there would only widen the allow-list.
RECOMMENDED_BY_ORGANIZATION: dict[str, dict[str, frozenset[str]]] = {
"ASF": {
"security": frozenset(
{
"mcp__ponymail__search_list",
"mcp__ponymail__auth_status",
"mcp__ponymail__get_thread",
"mcp__ponymail__get_email",
"mcp__ponymail__list_restrictions",
"mcp__apache-projects__project_stats",
"mcp__apache-projects__get_committee",
"mcp__apache-projects__get_group_members",
"mcp__apache-projects__get_person",
"mcp__apache-projects__search_people",
"Bash(vulnogram-api-record-fetch *)",
}
),
},
}


@dataclass(frozen=True)
class AuditFinding:
Expand Down Expand Up @@ -143,7 +156,9 @@ def has_findings(self) -> bool:
return bool(self.forbidden or self.missing_recommended)


def audit_settings(allow_list: Iterable[str], families: Iterable[str]) -> AuditResult:
def audit_settings(
allow_list: Iterable[str], families: Iterable[str], organization: str | None = None
) -> AuditResult:
"""Classify allow-list entries against forbidden + recommended lists.

Args:
Expand All @@ -152,6 +167,9 @@ def audit_settings(allow_list: Iterable[str], families: Iterable[str]) -> AuditR
families: the opt-in family names the adopter has selected
(e.g. `["security"]`). The empty-family bucket ("")
is always included regardless.
organization: the project's `organization` (`project.md`);
only that organization's own recommendations are added,
so the ASF-only ones appear for `ASF` alone.

Returns:
`AuditResult` with two buckets — entries to remove
Expand All @@ -178,8 +196,9 @@ def audit_settings(allow_list: Iterable[str], families: Iterable[str]) -> AuditR

# Recommended — always include the empty-family bucket.
effective_families = {""} | set(families)
by_org = RECOMMENDED_BY_ORGANIZATION.get(organization or "", {})
for family in sorted(effective_families):
recommended = RECOMMENDED_BY_FAMILY.get(family, frozenset())
recommended = RECOMMENDED_BY_FAMILY.get(family, frozenset()) | by_org.get(family, frozenset())
for pattern in sorted(recommended):
if pattern not in allow_set:
result.missing_recommended.append(
Expand Down
14 changes: 12 additions & 2 deletions tools/permission-audit/src/permission_audit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def _cmd_audit(args: argparse.Namespace) -> int:
settings_path = Path(args.settings_path).resolve()
allow = _read_allow(settings_path)
families = [f.strip() for f in (args.families or "").split(",") if f.strip()]
result = audit_settings(allow, families)
result = audit_settings(allow, families, args.organization)

output = {
"settings_path": str(settings_path),
Expand Down Expand Up @@ -188,7 +188,7 @@ def _cmd_audit_any(args: argparse.Namespace) -> int:
continue
if audit_kind == "claude":
allow = _read_allow(full_path)
r = audit_settings(allow, families)
r = audit_settings(allow, families, args.organization)
results.append(
{
"harness": harness_id,
Expand Down Expand Up @@ -250,6 +250,11 @@ def _build_parser() -> argparse.ArgumentParser:
default="",
help="Comma-separated opt-in family names (e.g. 'security,issue').",
)
p_audit.add_argument(
"--organization",
default=None,
help="The project's organization (project.md); ASF-only recommendations need 'ASF'.",
)
p_audit.set_defaults(func=_cmd_audit)

p_apply = subparsers.add_parser("apply", help="Atomically add/remove allow-list entries.")
Expand Down Expand Up @@ -291,6 +296,11 @@ def _build_parser() -> argparse.ArgumentParser:
default="",
help="Comma-separated opt-in family names for Claude Code audits (e.g. 'security,issue').",
)
p_audit_any.add_argument(
"--organization",
default=None,
help="The project's organization (project.md); ASF-only recommendations need 'ASF'.",
)
p_audit_any.set_defaults(func=_cmd_audit_any)

p_audit_oc = subparsers.add_parser(
Expand Down
21 changes: 19 additions & 2 deletions tools/permission-audit/tests/test_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
def test_empty_allow_only_misses_recommended():
result = audit_settings(allow_list=[], families=["security"])
assert result.forbidden == []
# lychee (default family "") + 15 security family = 16
assert len(result.missing_recommended) == 16
# lychee (default family "") + 4 generic security entries = 5
assert len(result.missing_recommended) == 5


def test_forbidden_python_wildcard_flagged_with_pointer():
Expand Down Expand Up @@ -68,3 +68,20 @@ def test_default_family_always_included_even_when_not_requested():
result = audit_settings(allow_list=[], families=["security"])
patterns = {f.pattern for f in result.missing_recommended}
assert "Bash(lychee *)" in patterns


def test_asf_only_recommendations_need_the_asf_organization():
"""PonyMail, the ASF project directory and Vulnogram are ASF services."""
asf_only = {
"mcp__ponymail__search_list",
"mcp__apache-projects__get_person",
"Bash(vulnogram-api-record-fetch *)",
}
elsewhere = {f.pattern for f in audit_settings([], ["security"]).missing_recommended}
independent = {f.pattern for f in audit_settings([], ["security"], "independent").missing_recommended}
asf = {f.pattern for f in audit_settings([], ["security"], "ASF").missing_recommended}
assert not asf_only & elsewhere
assert not asf_only & independent
assert asf_only <= asf
# lychee + 4 generic + 11 ASF security entries
assert len(asf) == 16
4 changes: 2 additions & 2 deletions tools/privacy-llm/checker/src/checker/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def _approve_by_opt_in(entry: LLMEntry, opt_in: list[OptInEntry]) -> Verdict:
False,
f"opt-in entry {opt.name!r} matches but the 'Approved-by' "
f"sub-bullet is missing or still has placeholder text "
f"({opt.approved_by!r}); a real PMC member must sign off.",
f"({opt.approved_by!r}); a member of the project's governing body must sign off.",
)
return Verdict(entry, True, f"opt-in entry {opt.name!r} (data-residency + approved-by present)")
return Verdict(
Expand Down Expand Up @@ -304,7 +304,7 @@ def _approve_endpoint_by_opt_in(
False,
f"opt-in entry {opt.name!r} matches but the 'Approved-by' "
f"sub-bullet is missing or still has placeholder text "
f"({opt.approved_by!r}); a real PMC member must sign off.",
f"({opt.approved_by!r}); a member of the project's governing body must sign off.",
)
return Verdict(entry, True, f"opt-in entry {opt.name!r} (data-residency + approved-by present)")

Expand Down
Loading