diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..9fef77c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,42 @@ +name: Bug report +description: Report reproducible incorrect or unsafe behavior +title: "Bug: " +labels: [bug] +body: + - type: markdown + attributes: + value: >- + Do not paste tokens, credentials, private transcripts, or sensitive + terminal output. Use private vulnerability reporting for security issues. + - type: input + id: versions + attributes: + label: Versions + description: CX Deck, macOS, iTerm2, Codex CLI, and zmx versions. + placeholder: "CX Deck 0.8.1; macOS …; iTerm2 …; Codex …; zmx …" + validations: + required: true + - type: input + id: command + attributes: + label: Command used + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + validations: + required: true + - type: textarea + id: doctor + attributes: + label: Redacted cx doctor output + description: Remove sensitive paths, hostnames, UUIDs, PIDs, and project names. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..79635f5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Report a security vulnerability privately + url: https://github.com/lrgthu/cxdeck/security/advisories/new + about: Use GitHub Private Vulnerability Reporting for security-sensitive reports. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..5a18960 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,23 @@ +name: Feature request +description: Propose a focused improvement to daily CX Deck use +title: "Feature: " +body: + - type: textarea + id: problem + attributes: + label: User problem + description: What repeated workflow or failure would this solve? + validations: + required: true + - type: textarea + id: outcome + attributes: + label: Desired outcome + description: Describe the user-visible result without assuming an implementation. + validations: + required: true + - type: textarea + id: safety + attributes: + label: Identity, runtime, or presentation impact + description: Note any effect on conversation identity, zmx runtime, or iTerm views. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..17de841 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ +## What changed + +## Why + +## Safety impact + +- [ ] Conversation identity rules are unchanged or explicitly reviewed. +- [ ] No unintended Codex/zmx restart or termination is possible. +- [ ] No hidden terminal input or prompt injection was added. +- [ ] Presentation-only changes preserve the exact zmx generation. + +## Validation + +- [ ] Unit suite +- [ ] Static and public-source checks +- [ ] Disposable integration when applicable diff --git a/.github/workflows/iterm-gui.yml b/.github/workflows/iterm-gui.yml index 44d5961..e58e52c 100644 --- a/.github/workflows/iterm-gui.yml +++ b/.github/workflows/iterm-gui.yml @@ -5,6 +5,9 @@ on: branches: ['release/**'] permissions: contents: read +concurrency: + group: iterm-gui-${{ github.ref }} + cancel-in-progress: true jobs: gui: runs-on: macos-latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 04ecda5..b9f9e08 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,7 @@ name: tests on: push: + branches: [main] pull_request: workflow_dispatch: inputs: @@ -10,6 +11,9 @@ on: default: false permissions: contents: read +concurrency: + group: tests-${{ github.workflow }}-${{ github.event.pull_request.head.ref || github.ref }} + cancel-in-progress: true jobs: test: strategy: @@ -27,7 +31,9 @@ jobs: if: runner.os == 'Linux' run: | sudo apt-get update && sudo apt-get install -y zsh build-essential - curl -fL https://github.com/neurosnap/zmx/releases/download/v0.8.1/zmx-0.8.1-linux-x86_64.tar.gz -o /tmp/zmx.tar.gz + curl --proto '=https' --tlsv1.2 --fail --location --show-error \ + https://github.com/neurosnap/zmx/releases/download/v0.8.1/zmx-0.8.1-linux-x86_64.tar.gz \ + -o /tmp/zmx.tar.gz echo 'dfd75720b942466f28870731cc86dbc07afa72fb8f3bd5eeb4ff707e4eecebe8 /tmp/zmx.tar.gz' | sha256sum -c - tar -xzf /tmp/zmx.tar.gz -C /tmp sudo install -m 0755 /tmp/zmx /usr/local/bin/zmx @@ -47,6 +53,8 @@ jobs: zsh -n uninstall.sh zsh -n tests/prepare_ci_iterm.zsh python3 -m compileall -q . + - name: Public-source privacy audit + run: python3 tools/public_release_audit.py - name: Unit and disposable real-backend tests (no real Codex API calls) run: python3 -m unittest discover -s tests -v - name: Install and register iTerm2 scripting dictionary (macOS) diff --git a/CHANGELOG.md b/CHANGELOG.md index 266167b..a159cfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,20 +2,18 @@ ## 0.8.1 +- Added exact native iTerm workspace capture and ordered split-tree restoration; + split ratios and window frames remain best-effort hints. +- Reuse and move verified iTerm Sessions without restarting Codex or replacing + zmx generations. +- Added unified conversation/runtime/view health, metadata-only search, + next/previous focus, and workspace-scoped presentation repair. - Materialize iTerm session GUID and TTY values before serializing live view inventory, avoiding an AppleScript coercion race immediately after supported session moves. - Allow disposable exact-restore acceptance enough time for iTerm to finish an asynchronous presentation-client close. -## 0.8.0 - -- Added exact native iTerm workspace capture and ordered split-tree restoration. -- Reuse and move verified iTerm Sessions without restarting Codex or replacing zmx generations. -- Added a unified conversation/runtime/view inventory, view-health status, metadata-only search, and next/previous focus. -- Added workspace-scoped presentation refresh and rebuild while preserving exact UUID and generation safety gates. -- Exact tree structure is verified after restoration; split ratios and window frames remain best-effort hints. - ## 0.7.0 - Renamed the product to CX Deck and prepared the repository for public review. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5109918..3d63d61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,21 @@ # Contributing -Open an issue before a large behavioral change. Keep zmx limited to persistent -PTY lifecycle, keep iTerm2 responsible for presentation, and preserve exact -Codex UUID identity. Do not add prompt injection, cross-pane input, terminal -content scraping, or hidden package installation. +For a focused change, fork the repository or create a branch, add relevant +tests, and open a pull request. Discuss large behavioral changes in an issue +first. Keep pull requests small enough to review as one safety contract. + +Preserve these architecture boundaries: + +```text +Codex UUID = durable conversation identity +zmx = persistent PTY/runtime only +iTerm2 = native presentation +CX Deck = control plane +``` + +Do not substitute titles, paths, PIDs, zmx names, or iTerm identifiers for exact +conversation identity. Do not add prompt injection, cross-pane input, terminal +content scraping, hidden package installation, or automatic session destruction. Run before opening a pull request: @@ -14,8 +26,15 @@ zsh -n cxdeck.zsh zsh -n install.sh zsh -n uninstall.sh zsh -n tests/prepare_ci_iterm.zsh +python3 tools/public_release_audit.py git diff --check ``` Real zmx and iTerm checks must use disposable runtime directories, fake Codex -processes, and temporary homes. Never use a person's active sessions as fixtures. +processes, temporary homes, and disposable iTerm views. Never use a person's +active sessions as fixtures. Describe which disposable integration checks you +ran in the pull request. + +See [the release procedure](docs/RELEASING.md) for maintainer-only release gates. +The public repository is the canonical development history. Never import Git +objects from historical private repositories into this graph. diff --git a/README.md b/README.md index e0ddedf..1ec2e5b 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ cd cxdeck ./install.sh source "$HOME/.cxdeck.zsh" cx doctor -cx upgrade status +cx ``` The installer checks prerequisites and installs CX Deck under @@ -74,8 +74,16 @@ cx # start a persistent Codex session here cx "Model Evaluation" # start or reuse an exact display name cx new --split # new native iTerm2 split cx resume # select from saved and live conversations -cx focus "Model Evaluation" cx status +cx dashboard + +cx workspace save daily +cx workspace capture daily # optional exact topology support +cx workspace open daily + +cx views status +cx find evaluation +cx focus --next ``` Closing the pane detaches the view. The Codex process remains in its zmx PTY. @@ -132,14 +140,14 @@ Display names, pins, groups, workspace membership, and layout metadata live in their UUID or runtime generation. A workspace records a provider-neutral native layout plan and reconstructs verified views without nesting another terminal UI. `cx workspace capture NAME [--replace]` can add an optional exact native split -tree after double-read verification. This read-only feature lazily requires the -explicitly installed `iterm2==2.23` Python package; membership-only workspaces -remain valid. Install that optional support into the same Python used by `cx` -with `python3 -m pip install --user 'iterm2==2.23'`. Opening a captured workspace resolves every exact UUID and checks -duplicates before moving verified iTerm Sessions into the saved tree. Frames and -ratios are best-effort hints. Use `cx workspace open NAME --adaptive` to -deliberately use the older adaptive layout; exact restore never falls back to it -silently. +tree after double-read verification. This feature lazily requires the explicitly +installed `iterm2==2.23` Python package; ordinary CX Deck commands and +membership-only workspaces do not. Install that optional support into the same +Python used by `cx` with `python3 -m pip install --user 'iterm2==2.23'`. +Opening a captured workspace resolves every exact UUID and checks duplicates +before moving verified iTerm Sessions into the saved tree. Frames and ratios are +best-effort hints. Use `cx workspace open NAME --adaptive` to deliberately use +the older adaptive layout; exact restore never falls back to it silently. `cx status`, `cx resume --json`, and `cx views status --json` use the normalized `cxdeck.inventory/v1` model. Conversation, runtime, external-process, and view @@ -199,7 +207,8 @@ Run `cx doctor` first. It reports Codex and zmx paths and versions, managed session counts, launch policies, unidentified external processes, iTerm2 automation, and upgrade compatibility. GUI errors leave Codex and zmx running; use `cx status` to inspect runtime health and `cx views rebuild` to reconstruct -missing views. +missing views. Before posting diagnostics publicly, remove sensitive hostnames, +paths, UUIDs, PIDs, and project names. ## Uninstall diff --git a/SECURITY.md b/SECURITY.md index fcc0a90..adb52cd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,8 +1,11 @@ # Security policy -Report a suspected vulnerability privately through GitHub's security advisory -feature for this repository. Do not include credentials, transcripts, or private -conversation data in a public issue. +Report suspected vulnerabilities through +[GitHub Private Vulnerability Reporting](https://github.com/lrgthu/cxdeck/security/advisories/new). +Do not open a public issue for credential exposure, session hijacking, arbitrary +command execution, identity confusion, or destructive runtime behavior. Never +include credentials, transcripts, private terminal output, or private +conversation data in a public report. Security fixes target the latest release branch. CX Deck treats exact Codex UUID identity, zmx generation verification, process ownership, private state-file diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..c17307b --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,46 @@ +# Releasing CX Deck + +CX Deck uses ordinary public Git history. The public repository is the canonical +development history; never import Git objects from historical private +repositories into this graph. + +## Version policy + +- Patch releases contain bug and compatibility fixes. +- Minor releases add backward-compatible features. +- Major releases may change conversation identity, runtime compatibility, or + the public CLI contract. + +The version lives in `cx_version.py`. A release updates that value and +`CHANGELOG.md` in the same pull request. A controller version change does not by +itself require running zmx generations to restart; runtime requirements remain +an explicit compatibility decision in `cx_upgrade.py`. + +## Release checks + +Run from a clean release branch: + +```zsh +python3 -m unittest discover -s tests -v +python3 -m compileall -q . +zsh -n cxdeck.zsh +zsh -n install.sh +zsh -n uninstall.sh +zsh -n tests/prepare_ci_iterm.zsh +python3 tools/public_release_audit.py +git diff --check +``` + +Run the six disposable real-zmx tests and `tools/zmx_behavior_probe.py`. When a +change affects presentation, also run the applicable disposable real-iTerm +capture, MOVE_REUSE, restore, and navigation acceptance tools. These tests must +use temporary homes, private zmx runtime directories, dummy processes, and +disposable iTerm views. Never use active user sessions as fixtures. + +Review the privacy audit, the complete pull-request diff, and the actual regular +CI job conclusions. Merge through a pull request. Then create and push an +annotated version tag and publish a GitHub Release from that exact tag. Never +move, replace, or delete an existing published version tag. + +Exact workspace tests require the explicitly installed `iterm2==2.23` package. +The package remains optional for ordinary CX Deck use. diff --git a/tests/test_public_release_audit.py b/tests/test_public_release_audit.py new file mode 100644 index 0000000..7d21bec --- /dev/null +++ b/tests/test_public_release_audit.py @@ -0,0 +1,50 @@ +"""Regression tests for the dependency-free public-source privacy gate.""" +from pathlib import Path +import tempfile +import unittest +from unittest import mock + +from tools import public_release_audit as audit + + +class PublicReleaseAuditTests(unittest.TestCase): + def test_current_public_tree_passes(self): + root = Path(__file__).resolve().parents[1] + self.assertEqual(audit.scan(root, audit.tracked_files(root)), []) + + def test_sensitive_markers_are_detected_without_echoing_values(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / "fixture.txt" + path.write_text("\n".join(( + "/" + "Users" + "/alice/project", + "ghp_" + "a" * 30, + "-----BEGIN " + "PRIVATE KEY-----", + "https://github.com/example/cxdeck-" + "private-archive", + ))) + categories = {row[0] for row in audit.scan(root, [path])} + self.assertEqual(categories, + {"PERSONAL_USER_PATH", "GITHUB_TOKEN", + "PRIVATE_KEY", "PRIVATE_ARCHIVE"}) + + def test_synthetic_paths_and_identifiers_are_allowed(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / "fixture.txt" + path.write_text("/home/test/.codex\n/Users/example/project\n" + "11174064+project@users.noreply.github.com\n" + "00000000-0000-4000-8000-000000000001\n") + self.assertEqual(audit.scan(root, [path]), []) + + def test_scan_does_not_resolve_network_fqdn(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / "fixture.txt" + path.write_text("ordinary public source\n") + with mock.patch.object(audit.socket, "getfqdn", + side_effect=AssertionError("network lookup")): + self.assertEqual(audit.scan(root, [path]), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/public_release_audit.py b/tools/public_release_audit.py new file mode 100644 index 0000000..f90aeea --- /dev/null +++ b/tools/public_release_audit.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Dependency-free privacy and secret check for tracked public source files.""" +from __future__ import annotations + +import argparse +import getpass +import os +from pathlib import Path +import re +import socket +import subprocess +import sys + + +MAX_TEXT_BYTES = 5 * 1024 * 1024 +ALLOWED_EMAIL_DOMAINS = {"example.com", "users.noreply.github.com"} +ALLOWED_USER_PATHS = {"example", "runner", "shared", "test"} + + +def tracked_files(root: Path): + result = subprocess.run( + ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"], + cwd=root, capture_output=True, check=True + ) + return [root / os.fsdecode(value) for value in result.stdout.split(b"\0") if value] + + +def _patterns(): + private_key = "BEGIN " + "PRIVATE KEY" + archive_name = "cxdeck-" + "private-archive" + users_path = re.escape("/" + "Users" + "/") + return ( + ("PRIVATE_KEY", re.compile(re.escape(private_key), re.I)), + ("GITHUB_TOKEN", re.compile(r"(?:ghp|github_pat)_[A-Za-z0-9_]{20,}")), + ("OPENAI_KEY", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}")), + ("AWS_KEY", re.compile(r"\bAKIA[0-9A-Z]{16}\b")), + ("GOOGLE_KEY", re.compile(r"\bAIza[0-9A-Za-z_-]{30,}\b")), + ("SLACK_TOKEN", re.compile(r"\bxox[a-z]-[0-9A-Za-z-]{20,}\b")), + ("AUTH_HEADER", re.compile(r"Authorization\s*:\s*Bearer\s+[A-Za-z0-9._~+/-]{12,}", re.I)), + ("SECRET_ASSIGNMENT", re.compile( + r"(?i)\b(?:password|passwd|token|secret|api_key)\s*=\s*['\"][^'\"]{8,}['\"]" + )), + ("PRIVATE_ARCHIVE", re.compile(re.escape(archive_name), re.I)), + ("PERSONAL_USER_PATH", re.compile(users_path + r"([^/\s'\"]+)")), + ) + + +EMAIL = re.compile(r"(?= 4 and value not in {"root", "runner"}} + findings = [] + for path in files: + path = Path(path) + relative = path.resolve().relative_to(root) + data = path.read_bytes() + if len(data) > MAX_TEXT_BYTES: + findings.append(("OVERSIZED_FILE", str(relative), 0)) + continue + if b"\0" in data: + findings.append(("BINARY_FILE", str(relative), 0)) + continue + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + findings.append(("NON_UTF8_FILE", str(relative), 0)) + continue + for number, line in enumerate(text.splitlines(), 1): + for category, pattern in patterns: + match = pattern.search(line) + if not match: + continue + if category == "PERSONAL_USER_PATH" and match.group(1).lower() in ALLOWED_USER_PATHS: + continue + findings.append((category, str(relative), number)) + for match in EMAIL.finditer(line): + if match.group(2).lower() not in ALLOWED_EMAIL_DOMAINS: + findings.append(("NONPUBLIC_EMAIL", str(relative), number)) + lowered = line.casefold() + if any(value.casefold() in lowered for value in dynamic): + findings.append(("LOCAL_IDENTITY", str(relative), number)) + return sorted(set(findings)) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1]) + args = parser.parse_args(argv) + root = args.root.resolve() + extra = [value.strip() for value in os.environ.get("CX_PUBLIC_AUDIT_FORBIDDEN", "").split(",")] + files = tracked_files(root) + findings = scan(root, files, extra) + if findings: + for category, path, line in findings: + print(f"{category}: {path}:{line}") + print(f"Public-source audit failed with {len(findings)} finding(s).", file=sys.stderr) + return 1 + print(f"Public-source audit passed: {len(files)} source files checked.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())