diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c769db3..3c93608 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,13 +20,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: ${{ matrix.node }} - cache: npm - name: Install run: npm install --ignore-scripts diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..a8b758c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + schedule: + - cron: '35 3 * * 1' + +permissions: + contents: read + +jobs: + analyze: + name: Analyze (javascript-typescript) + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: + - javascript-typescript + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6 + with: + languages: ${{ matrix.language }} + + - name: Autobuild + uses: github/codeql-action/autobuild@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 821e5ee..8aad9a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,9 @@ name: Release to npm (provenance) on: workflow_dispatch: + push: + tags: + - 'v*' release: types: [published] @@ -17,17 +20,17 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 22 registry-url: https://registry.npmjs.org cache: npm - name: Install - run: npm install --ignore-scripts + run: npm ci --ignore-scripts - name: Verify run: | diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index ac4fc86..5cfec6a 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -20,16 +20,19 @@ jobs: id-token: write actions: read contents: read + checks: read + issues: read + pull-requests: read steps: - name: Run analysis - uses: ossf/scorecard-action@v2.4.0 + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 with: results_file: results.sarif results_format: sarif publish_results: true - name: Upload SARIF results to Security tab - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6 with: sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 0e9106d..8be440a 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .omx/ +node_modules \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 7c9cb3f..159c9ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ This AGENTS.md is the top-level operating contract for this repository. - Reuse existing patterns before introducing new abstractions. - No new dependencies without explicit request. - Keep diffs small, reviewable, and reversible. +- When work from an agent-created branch is merged into `main`, always bump the npm package version and include the updated `package.json` and lockfile in that merge. - Run lint/typecheck/tests/static analysis after changes. - Final reports must include: changed files, simplifications made, and remaining risks. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cede6a4..d05f3c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ Thanks for contributing to `musafety`. ## Development setup ```bash -npm install +npm ci npm test node --check bin/multiagent-safety.js npm pack --dry-run diff --git a/README.md b/README.md index 4325a42..e9c90b7 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# musafety +# musafety (MULTI AGENTS SAFETY PROTCOL) [![npm version](https://img.shields.io/npm/v/musafety?color=cb3837&logo=npm)](https://www.npmjs.com/package/musafety) [![CI](https://github.com/recodeecom/multiagent-safety/actions/workflows/ci.yml/badge.svg)](https://github.com/recodeecom/multiagent-safety/actions/workflows/ci.yml) @@ -40,6 +40,7 @@ Package page: https://www.npmjs.com/package/musafety - Dedicated security disclosure policy in [`SECURITY.md`](./SECURITY.md) Related tools: + - [oh-my-codex (OMX)](https://github.com/Yeachan-Heo/oh-my-codex) - [OpenSpec](https://github.com/Fission-AI/OpenSpec) @@ -56,12 +57,37 @@ That one command runs: 2. asks strict Y/N approval only if something is missing, 3. installs guardrail scripts/hooks, 4. repairs common safety problems, -5. scans and reports final status. +5. installs local Codex + Claude musafety helper skill files if missing, +6. scans and reports final status. ## Setup screenshot ![musafety setup success screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/setup-success.svg) +## Status logs screenshot + +![musafety service status screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/musafety-service-status.svg) + +## AI helper skills installed by setup/doctor + +`musafety setup` and `musafety doctor` also ensure these local helper files exist: + +- Codex skill: `.codex/skills/musafety/SKILL.md` +- Claude command: `.claude/commands/musafety.md` (use as `/musafety`) + +## Scorecard report generation + +Create/update markdown reports from OpenSSF Scorecard JSON: + +```sh +musafety report scorecard --repo github.com/recodeecom/multiagent-safety +``` + +By default this writes: + +- `docs/reports/openssf-scorecard-baseline-YYYY-MM-DD.md` +- `docs/reports/openssf-scorecard-remediation-plan-YYYY-MM-DD.md` + ## Workflow protocol screenshots ### 1) Start isolated agent branch/worktree @@ -82,7 +108,36 @@ That one command runs: musafety copy-prompt ``` -This prints a ready-to-paste prompt. Example output: +This prints a ready-to-paste prompt. + +### Prompt preview (SVG) + +![musafety copy prompt screenshot](https://raw.githubusercontent.com/recodeecom/multiagent-safety/main/docs/images/copy-prompt-output.svg) + +### Commands-only copy mode + +If you only want executable commands (without explanatory text): + +```sh +musafety copy-commands +``` + +Example output: + +```sh +npm i -g musafety +musafety setup +musafety doctor +bash scripts/agent-branch-start.sh "task" "agent-name" +python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" +bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" +bash scripts/openspec/init-plan-workspace.sh "" +musafety protect add release staging +musafety sync --check +musafety sync +``` + +Full checklist output: ```text Use this exact checklist to setup multi-agent safety in this repository for Codex or Claude. @@ -99,8 +154,7 @@ Use this exact checklist to setup multi-agent safety in this repository for Code - n = skip global installs 3) If setup reports warnings/errors, repair + re-check: - musafety fix - musafety scan + musafety doctor 4) Confirm next safe agent workflow commands: bash scripts/agent-branch-start.sh "task" "agent-name" @@ -123,7 +177,9 @@ Use this exact checklist to setup multi-agent safety in this repository for Code ```sh musafety status [--target ] [--json] musafety setup [--target ] [--dry-run] [--yes-global-install|--no-global-install] [--no-gitignore] +musafety doctor [--target ] [--dry-run] [--json] [--keep-stale-locks] [--no-gitignore] musafety copy-prompt +musafety copy-commands musafety protect list [--target ] musafety protect add [--target ] musafety protect remove [--target ] @@ -131,12 +187,15 @@ musafety protect set [--target ] musafety protect reset [--target ] musafety sync --check [--target ] [--base ] [--json] musafety sync [--target ] [--base ] [--strategy rebase|merge] [--ff-only] +musafety report scorecard [--target ] [--repo github.com//] [--scorecard-json ] [--output-dir ] [--date YYYY-MM-DD] bash scripts/agent-worktree-prune.sh --base dev # manual stale worktree cleanup bash scripts/openspec/init-plan-workspace.sh # optional OpenSpec plan scaffold ``` No command defaults to `musafety status` (non-mutating health/status view). `musafety status` reports CLI/runtime info, global OMX/OpenSpec service status, and repo safety service state. +When run in an interactive terminal, default `musafety` checks npm for a newer version first +and asks `[Y/n]` whether to update immediately (default is `Y`). - Interactive setup: prompts for Y/N approval before global OMX/OpenSpec install. - Interactive prompt is strict (`[y/n]`) and waits for explicit answer. @@ -148,6 +207,7 @@ No command defaults to `musafety status` (non-mutating health/status view). musafety install [--target ] [--force] [--skip-agents] [--skip-package-json] [--no-gitignore] [--dry-run] musafety fix [--target ] [--dry-run] [--keep-stale-locks] [--no-gitignore] musafety scan [--target ] [--json] +musafety report help ``` ## Keep agent branches synced with dev @@ -160,6 +220,7 @@ musafety sync ``` Defaults: + - base branch: `dev` (or `multiagent.baseBranch`) - strategy: `rebase` (or `multiagent.sync.strategy`) @@ -226,6 +287,8 @@ scripts/agent-file-locks.py scripts/install-agent-git-hooks.sh scripts/openspec/init-plan-workspace.sh .githooks/pre-commit +.codex/skills/musafety/SKILL.md +.claude/commands/musafety.md .omx/state/agent-file-locks.json ``` diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index c0a4610..5bbbf87 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -14,6 +14,7 @@ const MAINTAINER_RELEASE_REPO = path.resolve( process.env.MUSAFETY_RELEASE_REPO || '/tmp/multiagent-safety', ); const NPM_BIN = process.env.MUSAFETY_NPM_BIN || 'npm'; +const SCORECARD_BIN = process.env.MUSAFETY_SCORECARD_BIN || 'scorecard'; const GIT_PROTECTED_BRANCHES_KEY = 'multiagent.protectedBranches'; const GIT_BASE_BRANCH_KEY = 'multiagent.baseBranch'; const GIT_SYNC_STRATEGY_KEY = 'multiagent.sync.strategy'; @@ -31,6 +32,8 @@ const TEMPLATE_FILES = [ 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', 'githooks/pre-commit', + 'codex/skills/musafety/SKILL.md', + 'claude/commands/musafety.md', ]; const EXECUTABLE_RELATIVE_PATHS = new Set([ @@ -63,6 +66,8 @@ const MANAGED_GITIGNORE_PATHS = [ 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', + '.codex/skills/musafety/SKILL.md', + '.claude/commands/musafety.md', LOCK_FILE_RELATIVE, ]; const COMMAND_TYPO_ALIASES = new Map([ @@ -71,12 +76,17 @@ const COMMAND_TYPO_ALIASES = new Map([ ['relase', 'release'], ['setpu', 'setup'], ['intsall', 'install'], + ['docter', 'doctor'], + ['doctro', 'doctor'], ['scna', 'scan'], ]); const SUGGESTIBLE_COMMANDS = [ 'status', 'setup', + 'doctor', + 'report', 'copy-prompt', + 'copy-commands', 'protect', 'sync', 'release', @@ -87,6 +97,23 @@ const SUGGESTIBLE_COMMANDS = [ 'help', 'version', ]; +const CLI_COMMAND_DESCRIPTIONS = [ + ['status', 'Show musafety CLI + service health without modifying files'], + ['setup', 'Install + repair guardrails in a git repo (supports --no-gitignore)'], + ['doctor', 'Repair safety setup drift, then verify repo safety'], + ['report', 'Generate security/safety reports (for example: OpenSSF scorecard)'], + ['copy-prompt', 'Print the AI-ready setup checklist'], + ['copy-commands', 'Print setup checklist as executable commands only'], + ['protect', 'Manage protected branches (list/add/remove/set/reset)'], + ['sync', 'Check or sync agent branches with origin/'], + ['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'], + ['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'], + ['scan', 'Report safety issues and exit non-zero on findings'], + ['print-agents-snippet', 'Print the AGENTS.md snippet template'], + ['release', 'Publish musafety from maintainer release repo'], + ['help', 'Show this help output'], + ['version', 'Print musafety version'], +]; const AI_SETUP_PROMPT = `Use this exact checklist to setup multi-agent safety in this repository for Codex or Claude. @@ -102,8 +129,7 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup multi-agent safety in - n = skip global installs 3) If setup reports warnings/errors, repair + re-check: - musafety fix - musafety scan + musafety doctor 4) Confirm next safe agent workflow commands: bash scripts/agent-branch-start.sh "task" "agent-name" @@ -121,6 +147,36 @@ const AI_SETUP_PROMPT = `Use this exact checklist to setup multi-agent safety in musafety sync `; +const AI_SETUP_COMMANDS = `npm i -g musafety +musafety setup +musafety doctor +bash scripts/agent-branch-start.sh "task" "agent-name" +python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" +bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" +bash scripts/openspec/init-plan-workspace.sh "" +musafety protect add release staging +musafety sync --check +musafety sync +`; + +const SCORECARD_RISK_BY_CHECK = { + 'Dangerous-Workflow': 'Critical', + 'Code-Review': 'High', + Maintained: 'High', + 'Binary-Artifacts': 'High', + 'Dependency-Update-Tool': 'High', + 'Token-Permissions': 'High', + Vulnerabilities: 'High', + 'Branch-Protection': 'High', + Fuzzing: 'Medium', + 'Pinned-Dependencies': 'Medium', + SAST: 'Medium', + 'Security-Policy': 'Medium', + 'CII-Best-Practices': 'Low', + Contributors: 'Low', + License: 'Low', +}; + function runtimeVersion() { return `${packageJson.name}/${packageJson.version} ${process.platform}-${process.arch} node-${process.version}`; } @@ -146,6 +202,52 @@ function statusDot(status) { return colorize('●', '33'); // yellow for degraded/unknown } +function commandCatalogLines(indent = ' ') { + const maxCommandLength = CLI_COMMAND_DESCRIPTIONS.reduce( + (max, [command]) => Math.max(max, command.length), + 0, + ); + return CLI_COMMAND_DESCRIPTIONS.map( + ([command, description]) => `${indent}${command.padEnd(maxCommandLength + 2)}${description}`, + ); +} + +function printToolLogsSummary() { + const usageLine = ` $ ${TOOL_NAME} [options]`; + const commandDetails = commandCatalogLines(' '); + + if (!supportsAnsiColors()) { + console.log('musafety-tools logs:'); + console.log(' USAGE'); + console.log(usageLine); + console.log(' COMMANDS'); + for (const line of commandDetails) { + console.log(line); + } + return; + } + + const title = colorize('musafety-tools logs', '1;36'); + const usageHeader = colorize('USAGE', '1'); + const commandsHeader = colorize('COMMANDS', '1'); + const pipe = colorize('│', '90'); + const tee = colorize('├', '90'); + const corner = colorize('└', '90'); + + console.log(`${title}:`); + console.log(` ${tee}─ ${usageHeader}`); + console.log(` ${pipe}${usageLine}`); + console.log(` ${tee}─ ${commandsHeader}`); + for (const line of commandDetails) { + if (!line) { + console.log(` ${pipe}`); + continue; + } + console.log(` ${pipe}${line.slice(2)}`); + } + console.log(` ${corner}─ ${colorize(`Try '${TOOL_NAME} doctor' for one-step repair + verification.`, '2')}`); +} + function usage(options = {}) { const { outsideGitRepo = false } = options; @@ -155,21 +257,10 @@ VERSION ${runtimeVersion()} USAGE - $ ${TOOL_NAME} [COMMAND] + $ ${TOOL_NAME} [options] COMMANDS - status Show musafety CLI + service health without modifying files - setup Install + repair guardrails in a git repo (supports --no-gitignore) - copy-prompt Print the AI-ready setup checklist - protect Manage protected branches (list/add/remove/set/reset) - sync Check or sync agent branches with origin/ - install Install templates/locks/hooks without running full setup (supports --no-gitignore) - fix Repair broken or missing guardrail files/config (supports --no-gitignore) - scan Report safety issues and exit non-zero on findings - print-agents-snippet Print the AGENTS.md snippet template - release Publish musafety from maintainer release repo - help Show this help output - version Print musafety version +${commandCatalogLines().join('\n')} NOTES - Running ${TOOL_NAME} with no command defaults to: ${TOOL_NAME} status @@ -189,6 +280,7 @@ function run(cmd, args, options = {}) { encoding: 'utf8', stdio: options.stdio || 'pipe', cwd: options.cwd, + timeout: options.timeout, }); } @@ -225,6 +317,12 @@ function toDestinationPath(relativeTemplatePath) { if (relativeTemplatePath.startsWith('githooks/')) { return `.${relativeTemplatePath}`; } + if (relativeTemplatePath.startsWith('codex/')) { + return `.${relativeTemplatePath}`; + } + if (relativeTemplatePath.startsWith('claude/')) { + return `.${relativeTemplatePath}`; + } throw new Error(`Unsupported template path: ${relativeTemplatePath}`); } @@ -379,6 +477,7 @@ function ensurePackageScripts(repoRoot, dryRun) { 'agent:safety:setup': `${TOOL_NAME} setup`, 'agent:safety:scan': `${TOOL_NAME} scan`, 'agent:safety:fix': `${TOOL_NAME} fix`, + 'agent:safety:doctor': `${TOOL_NAME} doctor`, }; pkg.scripts = pkg.scripts || {}; @@ -554,6 +653,204 @@ function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) { return { target, args: remaining }; } +function parseReportArgs(rawArgs) { + const options = { + target: process.cwd(), + subcommand: '', + repo: '', + scorecardJson: '', + outputDir: '', + date: '', + dryRun: false, + json: false, + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === '--target') { + const next = rawArgs[index + 1]; + if (!next) throw new Error('--target requires a path value'); + options.target = next; + index += 1; + continue; + } + if (arg === '--repo') { + const next = rawArgs[index + 1]; + if (!next) throw new Error('--repo requires a value like github.com/owner/repo'); + options.repo = next; + index += 1; + continue; + } + if (arg === '--scorecard-json') { + const next = rawArgs[index + 1]; + if (!next) throw new Error('--scorecard-json requires a path value'); + options.scorecardJson = next; + index += 1; + continue; + } + if (arg === '--output-dir') { + const next = rawArgs[index + 1]; + if (!next) throw new Error('--output-dir requires a path value'); + options.outputDir = next; + index += 1; + continue; + } + if (arg === '--date') { + const next = rawArgs[index + 1]; + if (!next) throw new Error('--date requires a YYYY-MM-DD value'); + options.date = next; + index += 1; + continue; + } + if (arg === '--dry-run') { + options.dryRun = true; + continue; + } + if (arg === '--json') { + options.json = true; + continue; + } + if (arg.startsWith('-')) { + throw new Error(`Unknown option: ${arg}`); + } + if (!options.subcommand) { + options.subcommand = arg; + continue; + } + throw new Error(`Unexpected argument: ${arg}`); + } + + return options; +} + +function todayDateStamp() { + return new Date().toISOString().slice(0, 10); +} + +function inferGithubRepoFromOrigin(repoRoot) { + const rawOrigin = readGitConfig(repoRoot, 'remote.origin.url'); + if (!rawOrigin) return ''; + + const httpsMatch = rawOrigin.match(/github\.com[:/](.+?)(?:\.git)?$/i); + if (!httpsMatch) return ''; + const slug = (httpsMatch[1] || '').replace(/^\/+/, '').trim(); + if (!slug || !slug.includes('/')) return ''; + return `github.com/${slug}`; +} + +function resolveScorecardRepo(repoRoot, explicitRepo) { + if (explicitRepo) { + return explicitRepo.trim(); + } + const inferred = inferGithubRepoFromOrigin(repoRoot); + if (inferred) return inferred; + throw new Error( + 'Unable to infer GitHub repo from origin remote. Pass --repo github.com//.', + ); +} + +function runScorecardJson(repo) { + const result = run(SCORECARD_BIN, ['--repo', repo, '--format', 'json'], { allowFailure: true }); + if (result.status !== 0) { + const details = (result.stderr || result.stdout || '').trim(); + throw new Error( + `Failed to run scorecard CLI ('${SCORECARD_BIN} --repo ${repo} --format json').${details ? `\n${details}` : ''}`, + ); + } + + try { + return JSON.parse(result.stdout || '{}'); + } catch (error) { + throw new Error(`Unable to parse scorecard JSON output: ${error.message}`); + } +} + +function readScorecardJsonFile(filePath) { + const absolute = path.resolve(filePath); + if (!fs.existsSync(absolute)) { + throw new Error(`scorecard JSON file not found: ${absolute}`); + } + try { + return JSON.parse(fs.readFileSync(absolute, 'utf8')); + } catch (error) { + throw new Error(`Unable to parse scorecard JSON file: ${error.message}`); + } +} + +function normalizeScorecardChecks(payload) { + const rawChecks = Array.isArray(payload?.checks) ? payload.checks : []; + return rawChecks.map((check) => { + const name = String(check?.name || 'Unknown'); + const rawScore = Number(check?.score); + const score = Number.isFinite(rawScore) ? rawScore : 0; + return { + name, + score, + risk: SCORECARD_RISK_BY_CHECK[name] || 'Unknown', + }; + }); +} + +function renderScorecardBaselineMarkdown({ repo, score, checks, capturedAt, scorecardVersion, reportDate }) { + const rows = checks + .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`) + .join('\n'); + + return [ + '# OpenSSF Scorecard Baseline Report', + '', + `- **Repository:** \`${repo}\``, + '- **Source:** generated by `musafety report scorecard`', + `- **Captured at:** ${capturedAt}`, + `- **Scorecard version:** \`${scorecardVersion}\``, + `- **Overall score:** **${score} / 10**`, + '', + '## Check breakdown', + '', + '| Check | Score | Risk |', + '|---|---:|---|', + rows || '| (none) | 0 | Unknown |', + '', + `## Report date`, + '', + `- ${reportDate}`, + '', + ].join('\n'); +} + +function renderScorecardRemediationPlanMarkdown({ baselineRelativePath, checks }) { + const failing = checks.filter((item) => item.score < 10); + const failingRows = failing + .sort((a, b) => a.score - b.score || a.name.localeCompare(b.name)) + .map((item) => `| ${item.name} | ${item.score} | ${item.risk} |`) + .join('\n'); + + return [ + '# OpenSSF Scorecard Remediation Plan', + '', + `Based on baseline report: \`${baselineRelativePath}\`.`, + '', + '## Failing checks', + '', + '| Check | Score | Risk |', + '|---|---:|---|', + (failingRows || '| None | 10 | N/A |'), + '', + '## Priority order', + '', + '1. Fix **High** risk checks first (especially score 0 items).', + '2. Then close **Medium** risk checks with score < 10.', + '3. Finally address **Low** risk ecosystem/process checks.', + '', + '## Verification loop', + '', + '1. Run scorecard again.', + '2. Re-generate baseline + remediation files.', + '3. Compare score deltas and track improved checks.', + '', + ].join('\n'); +} + function parseBranchList(rawValue) { return String(rawValue || '') .split(/[\s,]+/) @@ -850,6 +1147,135 @@ function promptYesNo(question, defaultYes = true) { } } +function envFlagEnabled(name) { + const raw = process.env[name]; + if (raw == null) return false; + return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase()); +} + +function parseAutoApproval(name) { + const raw = process.env[name]; + if (raw == null) return null; + const normalized = String(raw).trim().toLowerCase(); + if (['1', 'true', 'yes', 'y', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'n', 'off'].includes(normalized)) return false; + return null; +} + +function parseVersionString(version) { + const match = String(version || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!match) return null; + return [ + Number.parseInt(match[1], 10), + Number.parseInt(match[2], 10), + Number.parseInt(match[3], 10), + ]; +} + +function isNewerVersion(latest, current) { + const latestParts = parseVersionString(latest); + const currentParts = parseVersionString(current); + + if (!latestParts || !currentParts) { + return String(latest || '').trim() !== String(current || '').trim(); + } + + for (let index = 0; index < latestParts.length; index += 1) { + if (latestParts[index] > currentParts[index]) return true; + if (latestParts[index] < currentParts[index]) return false; + } + return false; +} + +function parseNpmVersionOutput(stdout) { + const trimmed = String(stdout || '').trim(); + if (!trimmed) return ''; + + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return String(parsed[parsed.length - 1] || '').trim(); + } + return String(parsed || '').trim(); + } catch { + const firstLine = trimmed.split('\n').map((line) => line.trim()).find(Boolean); + return firstLine || ''; + } +} + +function checkForMusafetyUpdate() { + if (envFlagEnabled('MUSAFETY_SKIP_UPDATE_CHECK')) { + return { checked: false, reason: 'disabled' }; + } + + const forceCheck = envFlagEnabled('MUSAFETY_FORCE_UPDATE_CHECK'); + if (!forceCheck && !isInteractiveTerminal()) { + return { checked: false, reason: 'non-interactive' }; + } + + const result = run(NPM_BIN, ['view', packageJson.name, 'version', '--json'], { timeout: 5000 }); + if (result.status !== 0) { + return { checked: false, reason: 'lookup-failed' }; + } + + const latest = parseNpmVersionOutput(result.stdout); + if (!latest) { + return { checked: false, reason: 'invalid-latest-version' }; + } + + return { + checked: true, + current: packageJson.version, + latest, + updateAvailable: isNewerVersion(latest, packageJson.version), + }; +} + +function printUpdateAvailableBanner(current, latest) { + const title = colorize('UPDATE AVAILABLE', '1;33'); + console.log(`[${TOOL_NAME}] ${title}`); + console.log(`[${TOOL_NAME}] Current: ${current}`); + console.log(`[${TOOL_NAME}] Latest : ${latest}`); + console.log(`[${TOOL_NAME}] Command: ${NPM_BIN} i -g ${packageJson.name}@latest`); +} + +function maybeSelfUpdateBeforeStatus() { + const check = checkForMusafetyUpdate(); + if (!check.checked || !check.updateAvailable) { + return; + } + + printUpdateAvailableBanner(check.current, check.latest); + + const autoApproval = parseAutoApproval('MUSAFETY_AUTO_UPDATE_APPROVAL'); + const interactive = isInteractiveTerminal(); + + if (!interactive && autoApproval == null) { + console.log(`[${TOOL_NAME}] Non-interactive shell; skipping auto-update prompt.`); + return; + } + + const shouldUpdate = autoApproval != null + ? autoApproval + : promptYesNo( + `Update now? (${NPM_BIN} i -g ${packageJson.name}@latest)`, + true, + ); + + if (!shouldUpdate) { + console.log(`[${TOOL_NAME}] Skipped update.`); + return; + } + + const installResult = run(NPM_BIN, ['i', '-g', `${packageJson.name}@latest`], { stdio: 'inherit' }); + if (installResult.status !== 0) { + console.log(`[${TOOL_NAME}] ⚠️ Update failed. You can retry manually.`); + return; + } + + console.log(`[${TOOL_NAME}] ✅ Updated to latest published version.`); +} + function promptYesNoStrict(question) { while (true) { process.stdout.write(`${question} [y/n] `); @@ -1329,6 +1755,7 @@ function status(rawArgs) { } console.log(`[${TOOL_NAME}] Repo: ${scanResult.repoRoot}`); console.log(`[${TOOL_NAME}] Branch: ${scanResult.branch}`); + printToolLogsSummary(); process.exitCode = 0; } @@ -1384,6 +1811,150 @@ function scan(rawArgs) { setExitCodeFromScan(result); } +function doctor(rawArgs) { + const options = parseCommonArgs(rawArgs, { + target: process.cwd(), + dropStaleLocks: true, + skipAgents: false, + skipPackageJson: false, + skipGitignore: false, + dryRun: false, + json: false, + }); + + const fixPayload = runFixInternal(options); + const scanResult = runScanInternal({ target: options.target, json: false }); + const musafe = scanResult.errors === 0 && scanResult.warnings === 0; + + if (options.json) { + process.stdout.write( + JSON.stringify( + { + repoRoot: scanResult.repoRoot, + branch: scanResult.branch, + musafe, + fix: { + operations: fixPayload.operations, + hookResult: fixPayload.hookResult, + dryRun: Boolean(options.dryRun), + }, + scan: { + errors: scanResult.errors, + warnings: scanResult.warnings, + findings: scanResult.findings, + }, + }, + null, + 2, + ) + '\n', + ); + setExitCodeFromScan(scanResult); + return; + } + + printOperations('Doctor/fix', fixPayload, options.dryRun); + printScanResult(scanResult, false); + if (musafe) { + console.log(`[${TOOL_NAME}] ✅ Repo is correctly musafe.`); + } else { + console.log( + `[${TOOL_NAME}] ⚠️ Repo is not fully musafe yet (${scanResult.errors} error(s), ${scanResult.warnings} warning(s)).`, + ); + } + setExitCodeFromScan(scanResult); +} + +function report(rawArgs) { + const options = parseReportArgs(rawArgs); + const subcommand = options.subcommand || 'help'; + if (subcommand === 'help' || subcommand === '--help' || subcommand === '-h') { + console.log( + `${TOOL_NAME} report commands:\n` + + ` ${TOOL_NAME} report scorecard [--target ] [--repo github.com//] [--scorecard-json ] [--output-dir ] [--date YYYY-MM-DD] [--dry-run] [--json]\n` + + `\n` + + `Examples:\n` + + ` ${TOOL_NAME} report scorecard --repo github.com/recodeecom/multiagent-safety\n` + + ` ${TOOL_NAME} report scorecard --scorecard-json ./scorecard.json --date 2026-04-10`, + ); + process.exitCode = 0; + return; + } + + if (subcommand !== 'scorecard') { + throw new Error(`Unknown report subcommand: ${subcommand}`); + } + + const repoRoot = resolveRepoRoot(options.target); + const repo = resolveScorecardRepo(repoRoot, options.repo); + const payload = options.scorecardJson + ? readScorecardJsonFile(options.scorecardJson) + : runScorecardJson(repo); + + const reportDate = options.date || todayDateStamp(); + const outputDir = path.resolve(options.outputDir || path.join(repoRoot, 'docs', 'reports')); + const baselinePath = path.join(outputDir, `openssf-scorecard-baseline-${reportDate}.md`); + const remediationPath = path.join(outputDir, `openssf-scorecard-remediation-plan-${reportDate}.md`); + + const checks = normalizeScorecardChecks(payload); + const rawScore = Number(payload?.score); + const score = Number.isFinite(rawScore) ? rawScore : 0; + const capturedAt = String(payload?.date || new Date().toISOString()); + const scorecardVersion = String(payload?.scorecard?.version || payload?.version || 'unknown'); + + const baselineMarkdown = renderScorecardBaselineMarkdown({ + repo, + score, + checks, + capturedAt, + scorecardVersion, + reportDate, + }); + + const remediationMarkdown = renderScorecardRemediationPlanMarkdown({ + baselineRelativePath: path.relative(repoRoot, baselinePath) || path.basename(baselinePath), + checks, + }); + + if (!options.dryRun) { + fs.mkdirSync(outputDir, { recursive: true }); + fs.writeFileSync(baselinePath, baselineMarkdown, 'utf8'); + fs.writeFileSync(remediationPath, remediationMarkdown, 'utf8'); + } + + if (options.json) { + process.stdout.write( + JSON.stringify( + { + repoRoot, + repo, + score, + checks: checks.length, + outputDir, + baselinePath, + remediationPath, + dryRun: Boolean(options.dryRun), + }, + null, + 2, + ) + '\n', + ); + process.exitCode = 0; + return; + } + + console.log(`[${TOOL_NAME}] Report target: ${repoRoot}`); + console.log(`[${TOOL_NAME}] Scorecard repo: ${repo}`); + console.log(`[${TOOL_NAME}] Score: ${score}/10`); + if (options.dryRun) { + console.log(`[${TOOL_NAME}] Dry run report paths:`); + } else { + console.log(`[${TOOL_NAME}] Generated reports:`); + } + console.log(` - ${baselinePath}`); + console.log(` - ${remediationPath}`); + process.exitCode = 0; +} + function setup(rawArgs) { const options = parseCommonArgs(rawArgs, { target: process.cwd(), @@ -1505,6 +2076,11 @@ function copyPrompt() { process.exitCode = 0; } +function copyCommands() { + process.stdout.write(AI_SETUP_COMMANDS); + process.exitCode = 0; +} + function sync(rawArgs) { const options = parseSyncArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); @@ -1795,6 +2371,7 @@ function main() { const args = process.argv.slice(2); if (args.length === 0) { + maybeSelfUpdateBeforeStatus(); status([]); return; } @@ -1822,11 +2399,26 @@ function main() { return; } + if (command === 'doctor') { + doctor(rest); + return; + } + + if (command === 'report') { + report(rest); + return; + } + if (command === 'copy-prompt') { copyPrompt(); return; } + if (command === 'copy-commands') { + copyCommands(); + return; + } + if (command === 'protect') { protect(rest); return; diff --git a/docs/images/copy-prompt-output.svg b/docs/images/copy-prompt-output.svg new file mode 100644 index 0000000..79af590 --- /dev/null +++ b/docs/images/copy-prompt-output.svg @@ -0,0 +1,38 @@ + + + + + + + Use this exact checklist to setup multi-agent safety in this repository for Codex or Claude. + + 1) Install (if missing): + npm i -g musafety + + 2) Bootstrap safety in this repo: + musafety setup + + - Setup detects global OMX/OpenSpec first. + - If one is missing and setup asks for approval, reply explicitly: + - y = run: npm i -g oh-my-codex @fission-ai/openspec (missing ones only) + - n = skip global installs + + 3) If setup reports warnings/errors, repair + re-check: + musafety doctor + + 4) Confirm next safe agent workflow commands: + bash scripts/agent-branch-start.sh "task" "agent-name" + python3 scripts/agent-file-locks.py claim --branch "$(git rev-parse --abbrev-ref HEAD)" <file...> + bash scripts/agent-branch-finish.sh --branch "$(git rev-parse --abbrev-ref HEAD)" + + 5) Optional: create OpenSpec planning workspace: + bash scripts/openspec/init-plan-workspace.sh "<plan-slug>" + + 6) Optional: protect extra branches: + musafety protect add release staging + + 7) Optional: sync your current agent branch with latest dev: + musafety sync --check + musafety sync + + diff --git a/docs/images/musafety-service-status.svg b/docs/images/musafety-service-status.svg new file mode 100644 index 0000000..bbb277f --- /dev/null +++ b/docs/images/musafety-service-status.svg @@ -0,0 +1,39 @@ + + + + + + + deadpool@recodee:~/YOUREPO$ musafety + + + + + [musafety] CLI: musafety/0.4.7 linux-x64 node-v22.22.0 + [musafety] Global services: + - ● oh-my-codex: active + - ● @fission-ai/openspec: active + [musafety] Repo safety service: ● active. + [musafety] Repo: /home/deadpool/YOUREPO + [musafety] Branch: ksskkfb02 + musafety-tools logs: + + ├─ USAGE + │ $ musafety <command> [options] + ├─ COMMANDS + │ status Show musafety CLI + service health without modifying files + │ setup Install + repair guardrails in a git repo (supports --no-gitignore) + │ doctor Repair safety setup drift, then verify repo safety + │ copy-prompt Print the AI-ready setup checklist + │ protect Manage protected branches (list/add/remove/set/reset) + │ sync Check or sync agent branches with origin/<base> + │ install Install templates/locks/hooks without running full setup (supports --no-gitignore) + │ fix Repair broken or missing guardrail files/config (supports --no-gitignore) + │ scan Report safety issues and exit non-zero on findings + │ print-agents-snippet Print the AGENTS.md snippet template + │ release Publish musafety from maintainer release repo + │ help Show this help output + │ version Print musafety version + └─ Try 'musafety doctor' for one-step repair + verification. + + diff --git a/docs/images/status-tools-logs.svg b/docs/images/status-tools-logs.svg new file mode 100644 index 0000000..7f10bb1 --- /dev/null +++ b/docs/images/status-tools-logs.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + musafety status + + + [musafety] CLI: musafety/0.4.6 linux-x64 node-v22.22.0 + [musafety] Global services: + - ● oh-my-codex: active + - ● @fission-ai/openspec: active + [musafety] Repo safety service: ● active. + [musafety] Repo: /home/deadpool/KFB-WIRELESS-CLIP-TESTER/GUI + [musafety] Branch: ksskkfb02 + musafety-tools logs: + + ├─ USAGE + + $ musafety <command> [options] + + ├─ COMMANDS + + status Show musafety CLI + service health without modifying files + + setup Install + repair guardrails in a git repo (supports --no-gitignore) + + doctor Repair safety setup drift, then verify repo safety + + copy-prompt Print the AI-ready setup checklist + + protect Manage protected branches (list/add/remove/set/reset) + + sync Check or sync agent branches with origin/<base> + └─ + Try 'musafety doctor' for one-step repair + verification. + + diff --git a/docs/reports/openssf-scorecard-baseline-2026-04-10.md b/docs/reports/openssf-scorecard-baseline-2026-04-10.md new file mode 100644 index 0000000..a1296e9 --- /dev/null +++ b/docs/reports/openssf-scorecard-baseline-2026-04-10.md @@ -0,0 +1,33 @@ +# OpenSSF Scorecard Baseline Report + +- **Repository:** `github.com/recodeecom/multiagent-safety` +- **Source:** user-provided OpenSSF Scorecard screenshot +- **Captured at:** 2026-04-10 (report timestamp shown in screenshot: `2026-04-10T08:48:47Z`) +- **Scorecard version:** `v5.0.0` +- **Overall score:** **5.8 / 10** + +## Check breakdown + +| Check | Score | Risk | +|---|---:|---| +| Dangerous-Workflow | 10 | Critical | +| Code-Review | 0 | High | +| Maintained | 0 | High | +| Binary-Artifacts | 10 | High | +| Dependency-Update-Tool | 10 | High | +| Token-Permissions | 10 | High | +| Vulnerabilities | 10 | High | +| Fuzzing | 0 | Medium | +| Pinned-Dependencies | 0 | Medium | +| SAST | 0 | Medium | +| Security-Policy | 10 | Medium | +| CII-Best-Practices | 0 | Low | +| Contributors | 0 | Low | +| License | 10 | Low | +| Branch-Protection | 3 | High | + +## Quick takeaways + +1. The biggest high-risk gaps are **Code-Review**, **Maintained**, and **Branch-Protection**. +2. The main medium-risk technical gaps are **Fuzzing**, **Pinned-Dependencies**, and **SAST**. +3. Core supply-chain and policy checks already look strong (Dangerous-Workflow, Token-Permissions, Vulnerabilities, Security-Policy, License). diff --git a/docs/reports/openssf-scorecard-remediation-plan-2026-04-10.md b/docs/reports/openssf-scorecard-remediation-plan-2026-04-10.md new file mode 100644 index 0000000..0c7f637 --- /dev/null +++ b/docs/reports/openssf-scorecard-remediation-plan-2026-04-10.md @@ -0,0 +1,49 @@ +# OpenSSF Scorecard Remediation Plan + +Based on baseline report: `docs/reports/openssf-scorecard-baseline-2026-04-10.md`. + +## Priority 0 (repository settings) + +These typically give the largest score gain fastest: + +1. **Code-Review (0 → target 10)** + - Enforce pull-request reviews on `main`. + - Require at least 1 approver (2 recommended for critical changes). + - Include administrators in enforcement. + +2. **Branch-Protection (3 → target 10)** + - Require status checks before merge. + - Disallow force pushes and branch deletion on protected branches. + - Require up-to-date branches before merge. + +3. **Maintained (0 → target 10)** + - Keep recent issue/PR activity and closure cadence visible. + - Maintain release cadence and changelog updates. + +## Priority 1 (automation & workflows) + +1. **SAST (0 → target 10)** + - Add and keep a CodeQL workflow enabled for JavaScript/TypeScript. + +2. **Pinned-Dependencies (0 → target 10)** + - Pin GitHub Action versions to immutable commit SHAs. + +3. **Fuzzing (0 → target 10)** + - Add automated fuzz/property testing in CI for CLI parsing and critical flows. + +## Priority 2 (ecosystem/process) + +1. **CII-Best-Practices (0 → target 10)** + - Enroll project in OpenSSF Best Practices badge program. + +2. **Contributors (0 → target 10)** + - Grow contributor diversity (multiple org/company affiliations where possible). + +## Verification loop after changes + +1. Re-run Scorecard report. +2. Update baseline report with new score + deltas. +3. Track changes in a short changelog section: + - date + - checks improved + - score delta diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..185662e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,66 @@ +{ + "name": "musafety", + "version": "0.4.7", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "musafety", + "version": "0.4.7", + "license": "MIT", + "bin": { + "multiagent-safety": "bin/multiagent-safety.js", + "musafety": "bin/multiagent-safety.js" + }, + "devDependencies": { + "fast-check": "^3.23.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/recodeecom" + } + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index 6042e25..0b0feea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "musafety", - "version": "0.4.6", + "version": "0.4.7", "description": "Simple setup command for hardened multi-agent collaboration safety in git repos.", "license": "MIT", "preferGlobal": true, @@ -41,5 +41,8 @@ "funding": "https://github.com/sponsors/recodeecom", "publishConfig": { "access": "public" + }, + "devDependencies": { + "fast-check": "^3.23.2" } } diff --git a/templates/claude/commands/musafety.md b/templates/claude/commands/musafety.md new file mode 100644 index 0000000..5b180c0 --- /dev/null +++ b/templates/claude/commands/musafety.md @@ -0,0 +1,18 @@ +# /musafety + +Run a musafety check-and-repair workflow for the current repository. + +## Steps + +1. Run `musafety status`. +2. If status is degraded, run `musafety doctor`. +3. If still degraded, run `musafety scan` and summarize each finding with a fix. +4. Report final verdict as one of: + - `Repo is musafe` + - `Repo is not musafe` (include blockers) + +## Style + +- Keep output short and operational. +- Include exact commands you executed. +- Prefer concrete next actions over generic advice. diff --git a/templates/codex/skills/musafety/SKILL.md b/templates/codex/skills/musafety/SKILL.md new file mode 100644 index 0000000..ceb2cd8 --- /dev/null +++ b/templates/codex/skills/musafety/SKILL.md @@ -0,0 +1,35 @@ +--- +name: musafety +description: "Use when you need to check, repair, or bootstrap multi-agent safety guardrails in this repository." +--- + +# musafety (Codex skill) + +Use this skill whenever branch safety, lock ownership, or guardrail setup may be broken. + +## Fast path + +1. Run `musafety status`. +2. If repo safety is degraded, run `musafety doctor`. +3. If issues remain, run `musafety scan` and address the findings. + +## Setup path + +If guardrails are missing entirely, run: + +```sh +musafety setup +``` + +Then verify: + +```sh +musafety status +musafety scan +``` + +## Operator notes + +- Prefer `musafety doctor` for one-step repair + verification. +- Keep agent work isolated (`agent/*` branches + lock claims). +- Do not bypass protected branch safeguards unless explicitly required. diff --git a/test/fuzzing.test.js b/test/fuzzing.test.js new file mode 100644 index 0000000..e40fb9e --- /dev/null +++ b/test/fuzzing.test.js @@ -0,0 +1,75 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); +const fc = require('fast-check'); + +const cliPath = path.resolve(__dirname, '..', 'bin', 'multiagent-safety.js'); + +const KNOWN_COMMON_FLAGS = new Set([ + '--target', + '--dry-run', + '--skip-agents', + '--skip-package-json', + '--force', + '--keep-stale-locks', + '--json', + '--yes-global-install', + '--no-global-install', + '--no-gitignore', +]); + +function runNode(args, cwd) { + return cp.spawnSync('node', [cliPath, ...args], { + cwd, + encoding: 'utf8', + env: process.env, + }); +} + +function runCmd(cmd, args, cwd) { + return cp.spawnSync(cmd, args, { + cwd, + encoding: 'utf8', + env: process.env, + }); +} + +function initRepo() { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fuzz-')); + const repoDir = path.join(tempDir, 'repo'); + fs.mkdirSync(repoDir); + + let result = runCmd('git', ['init', '-b', 'dev'], repoDir); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['config', 'user.email', 'bot@example.com'], repoDir); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['config', 'user.name', 'Bot'], repoDir); + assert.equal(result.status, 0, result.stderr); + + fs.writeFileSync( + path.join(repoDir, 'package.json'), + JSON.stringify({ name: 'demo', private: true, scripts: {} }, null, 2) + '\n', + 'utf8', + ); + + return repoDir; +} + +test('fuzz: status rejects unknown option patterns', () => { + const repoDir = initRepo(); + const unknownFlag = fc + .stringMatching(/^--[a-z][a-z-]{0,14}$/) + .filter((flag) => !KNOWN_COMMON_FLAGS.has(flag)); + + fc.assert( + fc.property(unknownFlag, (flag) => { + const result = runNode(['status', flag], repoDir); + assert.equal(result.status, 1, `expected non-zero for ${flag}`); + assert.match(`${result.stderr}${result.stdout}`, /Unknown option:/); + }), + { numRuns: 30 }, + ); +}); diff --git a/test/install.test.js b/test/install.test.js index 0eb8599..cb94abd 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -6,6 +6,9 @@ const path = require('node:path'); const cp = require('node:child_process'); const cliPath = path.resolve(__dirname, '..', 'bin', 'multiagent-safety.js'); +const cliVersion = JSON.parse( + fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'), +).version; function runNode(args, cwd) { return cp.spawnSync('node', [cliPath, ...args], { @@ -39,6 +42,14 @@ function createFakeNpmScript(scriptBody) { return fakeNpmPath; } +function createFakeScorecardScript(scriptBody) { + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-scorecard-')); + const fakePath = path.join(fakeBin, 'scorecard'); + fs.writeFileSync(fakePath, `#!/usr/bin/env bash\nset -e\n${scriptBody}\n`, 'utf8'); + fs.chmodSync(fakePath, 0o755); + return fakePath; +} + function initRepo() { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-')); const repoDir = path.join(tempDir, 'repo'); @@ -130,6 +141,10 @@ function aheadBehindCounts(repoDir, branchRef, baseRef) { }; } +function escapeRegexLiteral(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + test('setup provisions workflow files and repo config', () => { const repoDir = initRepo(); @@ -144,6 +159,8 @@ test('setup provisions workflow files and repo config', () => { 'scripts/install-agent-git-hooks.sh', 'scripts/openspec/init-plan-workspace.sh', '.githooks/pre-commit', + '.codex/skills/musafety/SKILL.md', + '.claude/commands/musafety.md', '.omx/state/agent-file-locks.json', '.gitignore', 'AGENTS.md', @@ -170,6 +187,8 @@ test('setup provisions workflow files and repo config', () => { assert.match(gitignoreContent, /scripts\/agent-branch-start\.sh/); assert.match(gitignoreContent, /scripts\/agent-file-locks\.py/); assert.match(gitignoreContent, /\.githooks\/pre-commit/); + assert.match(gitignoreContent, /\.codex\/skills\/musafety\/SKILL\.md/); + assert.match(gitignoreContent, /\.claude\/commands\/musafety\.md/); assert.match(gitignoreContent, /\.omx\/state\/agent-file-locks\.json/); assert.match(gitignoreContent, /# multiagent-safety:END/); @@ -193,9 +212,14 @@ test('default invocation runs non-mutating status output', () => { const serviceIdx = result.stdout.indexOf('[musafety] Repo safety service:'); const repoIdx = result.stdout.indexOf('[musafety] Repo:'); const branchIdx = result.stdout.indexOf('[musafety] Branch:'); + const toolsIdx = result.stdout.indexOf('musafety-tools logs:'); assert.equal(serviceIdx >= 0, true); assert.equal(repoIdx > serviceIdx, true); assert.equal(branchIdx > repoIdx, true); + assert.equal(toolsIdx > branchIdx, true); + assert.match(result.stdout, /musafety-tools logs:/); + assert.match(result.stdout, /USAGE\n\s+\$ musafety \[options\]/); + assert.match(result.stdout, /COMMANDS\n\s+status\s+Show musafety CLI \+ service health without modifying files/); assert.equal(fs.existsSync(path.join(repoDir, '.githooks', 'pre-commit')), false); }); @@ -209,6 +233,40 @@ test('default invocation outside git repo reports inactive repo service', () => assert.match(result.stdout, /Repo safety service: .*inactive/); }); +test('default invocation checks for update and can auto-approve latest install', () => { + const repoDir = initRepo(); + const markerPath = path.join(repoDir, '.self-update-called'); + const fakeNpm = createFakeNpmScript(` +if [[ "$1" == "view" ]]; then + echo '"9.9.9"' + exit 0 +fi +if [[ "$1" == "list" ]]; then + echo '{"dependencies":{"oh-my-codex":{},"@fission-ai/openspec":{}}}' + exit 0 +fi +if [[ "$1" == "i" && "$2" == "-g" && "$3" == "musafety@latest" ]]; then + echo "updated" > "${markerPath}" + exit 0 +fi +echo "unexpected npm args: $*" >&2 +exit 1 +`); + + const result = runNodeWithEnv([], repoDir, { + MUSAFETY_NPM_BIN: fakeNpm, + MUSAFETY_FORCE_UPDATE_CHECK: '1', + MUSAFETY_AUTO_UPDATE_APPROVAL: 'yes', + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /UPDATE AVAILABLE/); + assert.match(result.stdout, new RegExp(`Current:\\s+${escapeRegexLiteral(cliVersion)}`)); + assert.match(result.stdout, /Latest\s+:\s+9\.9\.9/); + assert.match(result.stdout, /Updated to latest published version/); + assert.equal(fs.existsSync(markerPath), true, 'expected self-update command to run'); +}); + test('status --json returns cli, services, and repo summary', () => { const repoDir = initRepo(); @@ -624,6 +682,80 @@ test('fix repairs stale lock issues so scan becomes clean', () => { assert.equal(result.status, 0, result.stdout + result.stderr); }); +test('doctor repairs setup drift and confirms repo is musafe', () => { + const repoDir = initRepo(); + + let result = runNode(['setup', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + // Simulate broken setup + stale lock. + fs.rmSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh')); + result = runCmd('git', ['config', 'core.hooksPath', '.git/hooks'], repoDir); + assert.equal(result.status, 0, result.stderr); + + const lockPath = path.join(repoDir, '.omx', 'state', 'agent-file-locks.json'); + fs.writeFileSync( + lockPath, + JSON.stringify( + { + locks: { + 'missing/file.ts': { + branch: 'agent/non-existent', + claimed_at: '2026-01-01T00:00:00Z', + allow_delete: false, + }, + }, + }, + null, + 2, + ) + '\n', + ); + + result = runNode(['doctor', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Doctor\/fix/); + assert.match(result.stdout, /Repo is correctly musafe/); + + const scanAfter = runNode(['scan', '--target', repoDir], repoDir); + assert.equal(scanAfter.status, 0, scanAfter.stderr || scanAfter.stdout); +}); + +test('report scorecard creates baseline + remediation reports', () => { + const repoDir = initRepo(); + const fakeScorecard = createFakeScorecardScript(` +if [[ "$1" == "--repo" && "$3" == "--format" && "$4" == "json" ]]; then + cat <<'JSON' +{"repo":{"name":"github.com/recodeecom/multiagent-safety"},"score":5.8,"date":"2026-04-10T08:48:47Z","scorecard":{"version":"v5.0.0"},"checks":[{"name":"Dangerous-Workflow","score":10},{"name":"Code-Review","score":0},{"name":"Branch-Protection","score":3}]} +JSON + exit 0 +fi +echo "unexpected scorecard args: $*" >&2 +exit 1 +`); + + const result = runNodeWithEnv( + ['report', 'scorecard', '--target', repoDir, '--repo', 'github.com/recodeecom/multiagent-safety', '--date', '2026-04-10'], + repoDir, + { MUSAFETY_SCORECARD_BIN: fakeScorecard }, + ); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /Generated reports:/); + + const baselinePath = path.join(repoDir, 'docs', 'reports', 'openssf-scorecard-baseline-2026-04-10.md'); + const remediationPath = path.join(repoDir, 'docs', 'reports', 'openssf-scorecard-remediation-plan-2026-04-10.md'); + assert.equal(fs.existsSync(baselinePath), true); + assert.equal(fs.existsSync(remediationPath), true); + + const baseline = fs.readFileSync(baselinePath, 'utf8'); + assert.match(baseline, /(\*\*)?Overall score:(\*\*)?\s+\*\*5\.8 \/ 10\*\*/); + assert.match(baseline, /\| Code-Review \| 0 \| High \|/); + + const remediation = fs.readFileSync(remediationPath, 'utf8'); + assert.match(remediation, /\| Branch-Protection \| 3 \| High \|/); + assert.match(remediation, /Verification loop/); +}); + test('copy-prompt outputs AI setup instructions', () => { const repoDir = initRepo(); const result = runNode(['copy-prompt'], repoDir); @@ -635,6 +767,18 @@ test('copy-prompt outputs AI setup instructions', () => { assert.match(result.stdout, /scripts\/agent-file-locks.py claim/); }); +test('copy-commands outputs command-only checklist', () => { + const repoDir = initRepo(); + const result = runNode(['copy-commands'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /^npm i -g musafety/m); + assert.match(result.stdout, /musafety setup/); + assert.match(result.stdout, /musafety doctor/); + assert.match(result.stdout, /scripts\/agent-file-locks.py claim/); + assert.match(result.stdout, /musafety sync --check/); + assert.doesNotMatch(result.stdout, /Use this exact checklist/); +}); + test('setup dry-run accepts explicit global install approval flags', () => { const repoDir = initRepo(); diff --git a/test/metadata.test.js b/test/metadata.test.js index f0e755e..413557a 100644 --- a/test/metadata.test.js +++ b/test/metadata.test.js @@ -25,3 +25,20 @@ test('release workflow publishes with provenance in CI', () => { const workflow = fs.readFileSync(workflowPath, 'utf8'); assert.match(workflow, /npm publish --provenance --access public/); }); + +test('security workflows are present and use pinned GitHub Actions SHAs', () => { + const workflowDir = path.join(repoRoot, '.github', 'workflows'); + const expected = ['ci.yml', 'release.yml', 'scorecard.yml', 'codeql.yml']; + for (const file of expected) { + const filePath = path.join(workflowDir, file); + assert.equal(fs.existsSync(filePath), true, `${file} missing`); + const content = fs.readFileSync(filePath, 'utf8'); + const usesLines = content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('uses: ')); + for (const line of usesLines) { + assert.match(line, /^uses:\s+\S+@[0-9a-f]{40}(\s+#.+)?$/, `${file} has unpinned action: ${line}`); + } + } +});