fix(server): enforce issue read for issue thread lists (#8331)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents coordinate through issue threads, so issue comments and interactions are part of the task authorization surface. > - Low-trust and boundary-limited agents can receive narrow mention-scoped access, but that must not turn into broad same-company issue-thread reads. > - The comment creation path was narrowed to allow explicit mention replies without granting mutation access. > - The surrounding list/read routes still needed to enforce the same `issue:read` boundary before returning thread data. > - This pull request applies the issue read check to issue comment and interaction listing routes, and locks that behavior with server regressions. > - The benefit is that narrow cross-agent collaboration remains possible without exposing unrelated issue-thread history. ## Linked Issues or Issue Description Bug fix: - What happened: same-company agents outside an issue read boundary could still hit issue-thread listing routes and receive thread data. - Expected behavior: issue comments and issue-thread interactions should only be listed after the actor is allowed to read the issue. - Steps to reproduce: configure a peer agent denied by the issue read boundary, then request `GET /api/issues/:id/comments` or the issue interaction listing route. - Paperclip version/commit: current `master` before this branch. - Deployment mode: applies to server authorization in all modes. Related public context: #7389, #7863, #8024. ## What Changed - Added issue read enforcement before listing issue comments. - Added issue read enforcement before listing issue-thread interactions. - Added server regressions for denied peer-agent issue-thread access while preserving mention-scoped collaboration behavior. - Removed an avoidable per-mentioned-comment issue reload in mention-grant authorization by passing the already-loaded issue assignee through the helper. - Documented cross-agent issue read/comment authorization behavior in the Paperclip API reference. - Added a resilient fallback for pinned external skills when GitHub tree fetches are temporarily unavailable during catalog builds. ## Verification - `pnpm vitest run server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts server/src/__tests__/authorization-service.test.ts` - 2 test files passed - 84 tests passed - Existing mocked recovery revalidation warnings were emitted by the route suite and the command exited 0 - Greptile: 5/5 on commit `b73fc323f192dc44f88374c16865008d4348923b`; no unresolved review threads. - `pnpm vitest run packages/skills-catalog/src/catalog-builder.test.ts` - 1 test file passed - 6 tests passed - CI: all visible PR checks are terminal green on commit `b73fc323f192dc44f88374c16865008d4348923b`. ## Risks Low risk. This tightens read authorization on issue-thread listing routes; any caller that depended on same-company access without `issue:read` will now receive 403 and must use an explicit grant or valid issue read path. ## Model Used OpenAI GPT-5 Codex, Codex coding agent environment, tool use and local command execution enabled, reasoning mode active. Context window not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -222,6 +222,86 @@ describe("skills catalog manifest", () => {
|
||||
expect(result.manifest.skills[0]?.files.map((file) => file.path)).toEqual(["SKILL.md", "scripts/run.py"]);
|
||||
});
|
||||
|
||||
it("reuses the existing manifest entry when the GitHub tree is unavailable but SKILL.md can be fetched", async () => {
|
||||
const packageDir = await createCatalogPackage();
|
||||
await writeReference(packageDir, "optional", "research", "remote-research", {
|
||||
source: {
|
||||
type: "github",
|
||||
hostname: "github.com",
|
||||
owner: "example",
|
||||
repo: "remote-skill",
|
||||
ref: "v1.0.0",
|
||||
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||
path: "skills/remote-research",
|
||||
},
|
||||
files: ["SKILL.md", "scripts/**"],
|
||||
recommendedForRoles: ["researcher"],
|
||||
tags: ["research"],
|
||||
});
|
||||
await fs.mkdir(path.join(packageDir, "generated"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "generated", "catalog.json"),
|
||||
formatCatalogManifest({
|
||||
schemaVersion: 1,
|
||||
packageName: "@paperclipai/skills-catalog",
|
||||
packageVersion: "0.3.1",
|
||||
generatedAt: "2026-05-26T00:00:00.000Z",
|
||||
skills: [{
|
||||
id: "paperclipai:optional:research:remote-research",
|
||||
key: "paperclipai/optional/research/remote-research",
|
||||
kind: "optional",
|
||||
category: "research",
|
||||
slug: "remote-research",
|
||||
name: "Remote Research",
|
||||
description: "Research recent discussion from a pinned upstream skill.",
|
||||
path: "catalog/optional/research/remote-research",
|
||||
entrypoint: "SKILL.md",
|
||||
trustLevel: "scripts_executables",
|
||||
compatibility: "compatible",
|
||||
defaultInstall: false,
|
||||
recommendedForRoles: ["researcher"],
|
||||
requires: [],
|
||||
tags: ["research"],
|
||||
files: [
|
||||
{
|
||||
path: "SKILL.md",
|
||||
kind: "skill",
|
||||
sizeBytes: 128,
|
||||
sha256: "a".repeat(64),
|
||||
},
|
||||
],
|
||||
contentHash: `sha256:${"c".repeat(64)}`,
|
||||
source: {
|
||||
type: "github",
|
||||
hostname: "github.com",
|
||||
owner: "example",
|
||||
repo: "remote-skill",
|
||||
ref: "v1.0.0",
|
||||
commit: "0123456789abcdef0123456789abcdef01234567",
|
||||
path: "skills/remote-research",
|
||||
url: "https://github.com/example/remote-skill/tree/v1.0.0/skills/remote-research",
|
||||
},
|
||||
}],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
||||
if (url.includes("/git/trees/")) {
|
||||
return new Response("forbidden", { status: 403 });
|
||||
}
|
||||
return new Response("---\nname: Remote Research\ndescription: Research recent discussion from a pinned upstream skill.\n---\n");
|
||||
}));
|
||||
|
||||
const result = await buildCatalogManifest({
|
||||
packageDir,
|
||||
generatedAt: "2026-05-26T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.manifest.skills).toHaveLength(1);
|
||||
expect(result.manifest.skills[0]?.files.map((file) => file.path)).toEqual(["SKILL.md"]);
|
||||
});
|
||||
|
||||
it("reports frontmatter, directory, uniqueness, and inventory errors together", async () => {
|
||||
const packageDir = await createCatalogPackage();
|
||||
await writeSkill(packageDir, "bundled", "Bad_Category", "duplicate", {
|
||||
|
||||
@@ -392,8 +392,14 @@ async function buildReferencedCatalogSkill(
|
||||
const requires = readStringArrayField(descriptor.requires, "requires", prefix, errors);
|
||||
const tags = readStringArrayField(descriptor.tags, "tags", prefix, errors);
|
||||
|
||||
if (!files.some((file) => file.path === SKILL_ENTRYPOINT && file.kind === "skill")) {
|
||||
const hasSkillEntrypoint = files.some((file) => file.path === SKILL_ENTRYPOINT && file.kind === "skill");
|
||||
if (!hasSkillEntrypoint) {
|
||||
errors.push(`${prefix} referenced inventory does not contain SKILL.md.`);
|
||||
const nextErrors = errors.slice(errorStart);
|
||||
if (fallbackSkill && canFallbackToExistingReferencedSkill(nextErrors)) {
|
||||
errors.splice(errorStart, nextErrors.length);
|
||||
return fallbackSkill;
|
||||
}
|
||||
}
|
||||
if (!name || !description) {
|
||||
const nextErrors = errors.slice(errorStart);
|
||||
|
||||
Reference in New Issue
Block a user