diff --git a/.github/merge-policy.json b/.github/merge-policy.json index 838aef1..ea9cc53 100644 --- a/.github/merge-policy.json +++ b/.github/merge-policy.json @@ -1,12 +1,19 @@ { "merge_method": "squash", "required_approvals": 0, - "required_code_owner_reviews": 0, + "required_code_owner_reviews": 1, "required_last_push_approvals": 0, "merge_queue": false, - "required_events": ["pull_request"], - "eligible_author_associations": ["OWNER", "MEMBER"], - "eligible_bot_logins": ["dependabot[bot]"], + "required_events": [ + "pull_request" + ], + "eligible_author_associations": [ + "OWNER", + "MEMBER" + ], + "eligible_bot_logins": [ + "dependabot[bot]" + ], "custom_merge_controller": false, "required_checks": [ { @@ -31,7 +38,9 @@ "/package.json", "/package-lock.json", "/npm-shrinkwrap.json", - "/skills/**" + "/skills/**", + "/.gitignore", + "/.gitattributes" ], "codeql_enforcement": "trusted_central_aggregate", "sensitive_review": { diff --git a/.github/ci-policy.mjs b/.github/verify.mjs similarity index 52% rename from .github/ci-policy.mjs rename to .github/verify.mjs index 04618bf..78a7ea6 100644 --- a/.github/ci-policy.mjs +++ b/.github/verify.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; import { resolve } from "node:path"; import { parseDocument } from "yaml"; @@ -11,62 +11,20 @@ const trackedFiles = execFileSync("git", ["-C", root, "ls-files", "-z"], { }) .split("\0") .filter(Boolean); -function trackedEntries(directory = ".") { - const prefix = directory === "." ? "" : `${directory.replace(/\/$/u, "")}/`; - const entries = new Set(); - for (const file of trackedFiles) { - if (!file.startsWith(prefix)) continue; - const remainder = file.slice(prefix.length); - if (!remainder) continue; - entries.add(remainder.split("/")[0]); - } - return [...entries].sort(); -} -function checkoutEntries(directory = ".") { - const entries = trackedEntries(directory); - if (directory === ".") entries.push(".git"); - return entries.sort(); -} -const TRUSTED_CONTROL_SHA = "f33da6bbcdfebd0693ff7673d750f369629e000e"; - -assert.equal(existsSync(resolve(root, ".npmrc")), false); -assert.equal(existsSync(resolve(root, "npm-shrinkwrap.json")), false); -assert.deepEqual( - readdirSync(resolve(root, ".github/workflows")).sort(), - ["trusted.yml"], -); -assert.equal( - readFileSync(resolve(root, ".github/workflows/trusted.yml"), "utf8"), - `name: OpenBoa Coffee trusted gate - -on: - pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] - -permissions: {} - -jobs: - trusted: - name: OpenBoa Coffee trusted required - permissions: - actions: read - contents: read - security-events: write - uses: openboa-ai/.github/.github/workflows/coffee-trusted-gate.yml@${TRUSTED_CONTROL_SHA} - with: - control_sha: ${TRUSTED_CONTROL_SHA} -`, - "trusted wrapper must remain exact", -); - -assert.deepEqual(checkoutEntries(), [ - ".claude-plugin", - ".codex-plugin", +// Compare complete paths: a same-named directory is not an allowed file. +// This is the published repository layout, not central security policy. +assert.deepEqual(trackedFiles.slice().sort(), [ + ".claude-plugin/plugin.json", + ".codex-plugin/plugin.json", ".editorconfig", - ".git", ".gitattributes", - ".githooks", - ".github", + ".githooks/pre-commit", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/dependabot.yml", + ".github/merge-policy.json", + ".github/verify.mjs", + ".github/verify.test.mjs", + ".github/workflows/trusted.yml", ".gitignore", "AGENTS.md", "CODEOWNERS", @@ -76,112 +34,12 @@ assert.deepEqual(checkoutEntries(), [ "package-lock.json", "package.json", "plugin.json", - "skills", -]); - -assert.deepEqual(readJson("package.json"), { - name: "@openboa-ai/coffee-chat", - version: "0.0.0", - private: true, - type: "module", - scripts: { - "hooks:install": "git config core.hooksPath .githooks", - verify: "node .github/ci-policy.mjs", - }, - dependencies: { yaml: "2.9.0" }, -}); -assert.deepEqual(readJson("package-lock.json"), { - name: "@openboa-ai/coffee-chat", - version: "0.0.0", - lockfileVersion: 3, - requires: true, - packages: { - "": { - name: "@openboa-ai/coffee-chat", - version: "0.0.0", - dependencies: { yaml: "2.9.0" }, - }, - "node_modules/yaml": { - version: "2.9.0", - resolved: "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - integrity: "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", - bin: { yaml: "bin.mjs" }, - engines: { node: ">= 14.6" }, - funding: { url: "https://github.com/sponsors/eemeli" }, - }, - }, -}); -assert.deepEqual(readdirSync(resolve(root, ".github")).sort(), [ - "PULL_REQUEST_TEMPLATE.md", - "ci-policy.mjs", - "dependabot.yml", - "merge-policy.json", - "workflows", -]); -assert.deepEqual(readdirSync(resolve(root, ".githooks")).sort(), ["pre-commit"]); -const expectedHook = [ - "#!/bin/sh", - "set -eu", - "", - "scanner=${GITLEAKS_BIN:-gitleaks}", - 'if ! command -v "$scanner" >/dev/null 2>&1; then', - " printf '%s\\n' 'Gitleaks is required; install Gitleaks before committing.' >&2", - " exit 1", - "fi", - "", - "if [ -e .gitleaks.toml ] || [ -e .gitleaksignore ]; then", - " printf '%s\\n' 'Repository-local Gitleaks controls are not permitted.' >&2", - " exit 1", - "fi", - "unset GITLEAKS_CONFIG GITLEAKS_CONFIG_TOML", - '"$scanner" git --pre-commit --staged --gitleaks-ignore-path /dev/null \\', - " --ignore-gitleaks-allow --redact --no-banner .", - 'staged_dir="$(mktemp -d)"', - `trap 'rm -rf "$staged_dir"' EXIT HUP INT TERM`, - 'git checkout-index --all --prefix="$staged_dir/"', - '"$scanner" dir --gitleaks-ignore-path /dev/null --ignore-gitleaks-allow \\', - ' --redact --no-banner "$staged_dir"', - "", -].join("\n"); -assert.equal(readFileSync(resolve(root, ".githooks/pre-commit"), "utf8"), expectedHook); -assert.notEqual(statSync(resolve(root, ".githooks/pre-commit")).mode & 0o111, 0); -assert.equal( - readFileSync(resolve(root, ".gitignore"), "utf8"), - `node_modules/ -build/ -coverage/ -__pycache__/ -*.py[cod] -*.log -.DS_Store - -# Local credentials -.env -.env.* -!.env.example -credentials.json -secrets.json -*.private.pem -private-key.pem -*.private.key -private.key -private-key.key -id_rsa -id_dsa -id_ecdsa -id_ed25519 -tls.key -server.key -server-key.pem -*-private-key.pem -*-private-key.key -privkey*.pem -*.p12 -*.pfx -*.jks -`, - ".gitignore must preserve the credential and local-artifact ignore contract", -); + "skills/brew/SKILL.md", + "skills/roast/SKILL.md" +], "unexpected or missing repository file"); +for (const path of trackedFiles) { + assert.equal(lstatSync(resolve(root, path)).isFile(), true, `${path}: regular file required`); +} const portable = readJson("plugin.json"); const manifestKeys = [ @@ -310,50 +168,6 @@ assert.equal(typeof codex.interface.longDescription, "string"); assert.ok(codex.interface.longDescription.trim().length > 0, "codex.interface.longDescription must be non-empty"); assertHttpsUrl(codex.interface.websiteURL, "codex.interface.websiteURL"); -assert.deepEqual(readJson(".github/merge-policy.json"), { - merge_method: "squash", - required_approvals: 0, - required_code_owner_reviews: 0, - required_last_push_approvals: 0, - merge_queue: false, - required_events: ["pull_request"], - eligible_author_associations: ["OWNER", "MEMBER"], - eligible_bot_logins: ["dependabot[bot]"], - custom_merge_controller: false, - required_checks: [ - { - context: "OpenBoa Coffee trusted required / OpenBoa Coffee trusted required", - integration_id: 15368, - }, - ], - protected_paths: [ - "/plugin.json", - "/.github/**", - "/.githooks/**", - "/.gitleaksignore", - "/.gitleaks.toml", - "/.codex-plugin/**", - "/.claude-plugin/**", - "/AGENTS.md", - "/CODEOWNERS", - "/README.md", - "/LICENSE", - "/SECURITY.md", - "/.npmrc", - "/package.json", - "/package-lock.json", - "/npm-shrinkwrap.json", - "/skills/**", - ], - codeql_enforcement: "trusted_central_aggregate", - sensitive_review: { - enforcement: "github_environment", - environment: "coffee-security", - required_approvals: 1, - prevent_self_review: false, - }, -}); - for (const forbidden of [ "mcp.json", ".mcp.json", @@ -373,98 +187,6 @@ for (const forbidden of [ assert.equal(existsSync(resolve(root, forbidden)), false, forbidden); } -assert.equal( - readFileSync(resolve(root, ".github/dependabot.yml"), "utf8"), - `version: 2 - -updates: - - package-ecosystem: npm - directory: "/" - schedule: - interval: weekly - open-pull-requests-limit: 5 - commit-message: - prefix: deps - allow: - - dependency-name: "*" - update-types: - - version-update:semver-minor - - version-update:semver-patch - groups: - security: - applies-to: security-updates - patterns: - - "*" - production-minor-patch: - applies-to: version-updates - dependency-type: production - update-types: - - minor - - patch - patterns: - - "*" - development-minor-patch: - applies-to: version-updates - dependency-type: development - update-types: - - minor - - patch - patterns: - - "*" - - - package-ecosystem: github-actions - directory: "/" - schedule: - interval: weekly - open-pull-requests-limit: 5 - commit-message: - prefix: deps - allow: - - dependency-name: "*" - update-types: - - version-update:semver-minor - - version-update:semver-patch - groups: - security: - applies-to: security-updates - patterns: - - "*" - compatible-actions: - applies-to: version-updates - update-types: - - minor - - patch - patterns: - - "*" -`, - "Dependabot policy must remain bounded to approved update lanes", -); -assert.equal( - readFileSync(resolve(root, "CODEOWNERS"), "utf8"), - `# Ownership routes review context; GitHub requires zero human approvals. -/.github/** @openboa -/.codex-plugin/** @openboa -/.claude-plugin/** @openboa -/AGENTS.md @openboa -/CODEOWNERS @openboa -/README.md @openboa -/.npmrc @openboa-ai/security-maintainers -/LICENSE @openboa -/SECURITY.md @openboa -/skills/** @openboa -/package.json @openboa -/package-lock.json @openboa -/npm-shrinkwrap.json @openboa-ai/security-maintainers -/plugin.json @openboa -`, - "CODEOWNERS must preserve the product ownership routes", -); -assert.match( - readFileSync(resolve(root, "SECURITY.md"), "utf8"), - /security@openboa\.ai/u, - "SECURITY.md must provide a private reporting channel", -); - const skillRoot = resolve(root, "skills"); const skills = readdirSync(skillRoot).sort(); assert.deepEqual(skills, ["brew", "roast"]); @@ -509,4 +231,4 @@ for (const skill of skills) { assert.equal(fields.name, skill, `${skill}: frontmatter name`); } -console.log("Coffee Chat structure and manifest policy passed."); +console.log("Coffee Chat structure and manifest verification passed."); diff --git a/.github/verify.test.mjs b/.github/verify.test.mjs new file mode 100644 index 0000000..a2031b5 --- /dev/null +++ b/.github/verify.test.mjs @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; + +const source = resolve(import.meta.dirname, ".."); +function fixture(mutate = () => {}) { + const root = mkdtempSync(join(tmpdir(), "coffee-chat-verify-")); + const env = { ...process.env, CI_POLICY_ROOT: root }; + delete env.GIT_INDEX_FILE; + try { + const files = execFileSync("git", ["ls-files", "-z"], { cwd: source, encoding: "utf8", env }).split("\0").filter(Boolean); + for (const path of files) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + cpSync(join(source, path), join(root, path)); + } + mutate(root); + execFileSync("git", ["init", "-q"], { cwd: root, env }); + execFileSync("git", ["add", "-f", "--all"], { cwd: root, env }); + return spawnSync(process.execPath, [join(source, ".github/verify.mjs")], { cwd: root, env, encoding: "utf8" }); + } finally { rmSync(root, { recursive: true, force: true }); } +} + +for (const path of [".github/product-behavior.js", ".githooks/eval-results.json"]) { + test(`rejects unexpected infrastructure artifact: ${path}`, () => { + const result = fixture((root) => writeFileSync(join(root, path), "{}")); + assert.notEqual(result.status, 0, result.stdout); + }); +} + +for (const path of ["LICENSE", "AGENTS.md", "SECURITY.md"]) { + test(`rejects a directory replacing the file: ${path}`, () => { + const result = fixture((root) => { + rmSync(join(root, path)); + mkdirSync(join(root, path)); + writeFileSync(join(root, path, "eval-results.json"), "{}"); + }); + assert.notEqual(result.status, 0, result.stdout); + }); +} + +test("accepts the current repository contract", () => { + const result = fixture(); + assert.equal(result.status, 0, result.stderr); +}); + +test("rejects divergent host identity", () => { + const result = fixture((root) => { + const path = join(root, ".codex-plugin/plugin.json"); + const manifest = JSON.parse(readFileSync(path, "utf8")); + manifest.name = "wrong-plugin"; + writeFileSync(path, JSON.stringify(manifest)); + }); + assert.notEqual(result.status, 0, result.stdout); +}); +test("rejects invalid Skill frontmatter", () => { + const result = fixture((root) => writeFileSync(join(root, "skills/roast/SKILL.md"), "---\nname: other\ndescription: test\n---\n")); + assert.notEqual(result.status, 0, result.stdout); +}); diff --git a/.github/workflows/trusted.yml b/.github/workflows/trusted.yml index b63f0c6..1e507cf 100644 --- a/.github/workflows/trusted.yml +++ b/.github/workflows/trusted.yml @@ -13,6 +13,6 @@ jobs: actions: read contents: read security-events: write - uses: openboa-ai/.github/.github/workflows/coffee-trusted-gate.yml@f33da6bbcdfebd0693ff7673d750f369629e000e + uses: openboa-ai/.github/.github/workflows/coffee-trusted-gate.yml@5b4d641ad1951c529d0a88f08df2544d25c3811a with: - control_sha: f33da6bbcdfebd0693ff7673d750f369629e000e + control_sha: 5b4d641ad1951c529d0a88f08df2544d25c3811a diff --git a/CODEOWNERS b/CODEOWNERS index 25df5c9..2d5dca1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,15 +1,15 @@ -# Ownership routes review context; GitHub requires zero human approvals. -/.github/** @openboa -/.codex-plugin/** @openboa -/.claude-plugin/** @openboa -/AGENTS.md @openboa -/CODEOWNERS @openboa -/README.md @openboa -/.npmrc @openboa-ai/security-maintainers -/LICENSE @openboa -/SECURITY.md @openboa -/skills/** @openboa -/package.json @openboa -/package-lock.json @openboa -/npm-shrinkwrap.json @openboa-ai/security-maintainers -/plugin.json @openboa +# Ownership routes require independent review; existing owners are preserved. +/.github/** @openboa @SonSangjoon +/.codex-plugin/** @openboa @SonSangjoon +/.claude-plugin/** @openboa @SonSangjoon +/AGENTS.md @openboa @SonSangjoon +/CODEOWNERS @openboa @SonSangjoon +/README.md @openboa @SonSangjoon +/.npmrc @openboa-ai/security-maintainers @SonSangjoon +/LICENSE @openboa @SonSangjoon +/SECURITY.md @openboa @SonSangjoon +/skills/** @openboa @SonSangjoon +/package.json @openboa @SonSangjoon +/package-lock.json @openboa @SonSangjoon +/npm-shrinkwrap.json @openboa-ai/security-maintainers @SonSangjoon +/plugin.json @openboa @SonSangjoon diff --git a/README.md b/README.md index 6479cf5..06498d8 100644 --- a/README.md +++ b/README.md @@ -159,3 +159,15 @@ Product against the qualified coffee-chat-bench cases. Coffee Chat is MIT licensed, Copyright © 2026 Openboa AI. Origin and Bean content rights are determined by the owner and the Roastery's applicable policy. + +## Repository verification + +Run `npm ci --ignore-scripts --no-bin-links`, then `npm run verify`. +The checks and regressions in `.github/verify.mjs` and `.github/verify.test.mjs` +are owned here. The pinned central workflow owns security policy, execution +isolation and approval; it does not define this repository's product or data +layout. Existing CODEOWNERS routes and protected paths remain in force. + +The same verify command runs locally and in the isolated CI lane. A changed +central pin needs control review; never remove a required check to upgrade it. +Structural CI success does not establish benchmark validity or Product lift. diff --git a/package.json b/package.json index d516e7b..71edf9b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "hooks:install": "git config core.hooksPath .githooks", - "verify": "node .github/ci-policy.mjs" + "verify": "node --test .github/verify.test.mjs && node .github/verify.mjs" }, "dependencies": { "yaml": "2.9.0"