diff --git a/.github/workflows/file-observation-regression.yml b/.github/workflows/file-observation-regression.yml index 271836fad..58eb5db21 100644 --- a/.github/workflows/file-observation-regression.yml +++ b/.github/workflows/file-observation-regression.yml @@ -152,6 +152,38 @@ jobs: path: ${{ runner.temp }}/observation-membership-base.log if-no-files-found: error retention-days: 14 + - name: Verify pending observer ownership without the fix + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + python3 - <<'PYCONTROL' + import os,re,subprocess + from pathlib import Path + owner=Path('crates/cyber-tools/src/file_changes/owner.rs') + source=owner.read_text() + needle='self.sessions.is_empty() && !self.waiting_for_previous' + assert source.count(needle)==1 + try: + owner.write_text(source.replace(needle,'self.sessions.is_empty()')) + name='file_changes::owner::tests::readmission_reuses_one_pending_replacement_until_previous_owner_settles' + result=subprocess.run(['cargo','test','--locked','-p','cyber-tools','--lib',name,'--','--exact'],capture_output=True,text=True) + log=result.stdout+result.stderr + Path(os.environ['RUNNER_TEMP'],'observation-pending-owner-base.log').write_text(log) + assert result.returncode!=0, log[-8000:] + assert re.search(r'^test '+re.escape(name)+r' \.\.\. FAILED$',log,re.M), log[-8000:] + assert 'readmission must not grow a task chain behind a settling owner' in log, log[-8000:] + finally: + owner.write_text(source) + PYCONTROL + - name: Preserve pending observer ownership regression evidence + if: always() && runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: pending-observer-owner-baseline + path: ${{ runner.temp }}/observation-pending-owner-base.log + if-no-files-found: error + retention-days: 14 - name: Verify known-private retirement without the fix if: runner.os == 'Linux' shell: bash diff --git a/.github/workflows/plugin-confinement-proof.yml b/.github/workflows/plugin-confinement-proof.yml index 027ea1567..02d597cf1 100644 --- a/.github/workflows/plugin-confinement-proof.yml +++ b/.github/workflows/plugin-confinement-proof.yml @@ -139,6 +139,7 @@ jobs: with: node-version: 22 - name: Verify capability matrix and original-signature exec controls + id: native_setup shell: bash run: | set -euo pipefail @@ -146,8 +147,155 @@ jobs: # matching-identity counterexamples and descriptor isolation. # The historical re-signing experiment remains an opt-in diagnostic; # its failed startup control is never counted as confinement. - node --experimental-strip-types scripts/probes/plugin-confinement/run.mts --runtime native --macos-signing-native --report "$RUNNER_TEMP/matrix.json" > "$RUNNER_TEMP/matrix.log" 2>&1 + node --experimental-strip-types scripts/probes/plugin-confinement/run.mts --runtime native --macos-signing-native --macos-private-unix --report "$RUNNER_TEMP/matrix.json" > "$RUNNER_TEMP/matrix.log" 2>&1 node --experimental-strip-types scripts/probes/plugin-confinement/macos_signing_exec.mts --report "$RUNNER_TEMP/exec.json" > "$RUNNER_TEMP/exec.log" 2>&1 + - name: Reject missing scratch UNIX policy + shell: bash + run: | + set -euo pipefail + control="$RUNNER_TEMP/macos-private-unix-missing-grant" + python3 - "$control" <<'PYCONTROL' + from pathlib import Path + import shutil, sys + target = Path(sys.argv[1]) + shutil.copytree('scripts/probes/plugin-confinement', target, ignore=shutil.ignore_patterns('evidence', '__pycache__')) + driver = target / 'macos_policy.mts' + text = driver.read_text() + before = ' if (privateUnix) {' + assert text.count(before) == 1 + driver.write_text(text.replace(before, ' if (false) { // Missing scratch UNIX grant control.')) + PYCONTROL + log="$RUNNER_TEMP/private-unix-missing-grant.log" + if node --experimental-strip-types "$control/run.mts" --runtime native --macos-signing-native --macos-private-unix --report "$control/result.json" > "$log" 2>&1; then + echo 'Missing private UNIX policy unexpectedly passed' >&2 + exit 1 + fi + python3 - "$log" <<'PYCONTROL' + from pathlib import Path + import sys + text = Path(sys.argv[1]).read_text() + assert 'no-capabilities: private UNIX runtime exchange failed: [false,false]' in text, text + PYCONTROL + - name: Verify owned UNIX endpoints and native peer identity + shell: bash + run: | + set -euo pipefail + node --experimental-strip-types scripts/probes/plugin-confinement/macos_private_ipc.mts --report "$RUNNER_TEMP/unix-boundary.json" > "$RUNNER_TEMP/unix-boundary.log" 2>&1 + - name: Reject widened UNIX endpoint scope independently + if: always() && !cancelled() && steps.native_setup.outcome == 'success' + shell: bash + run: | + set -euo pipefail + log="$RUNNER_TEMP/unix-missing-scope.log" + if node --experimental-strip-types scripts/probes/plugin-confinement/macos_private_ipc.mts --missing-scope > "$log" 2>&1; then + echo 'Missing UNIX outbound scope unexpectedly passed' >&2 + exit 1 + fi + python3 - "$log" <<'PYCONTROL' + from pathlib import Path + import sys + text = Path(sys.argv[1]).read_text() + assert 'macOS datagram endpoint escaped scratch:' in text, text + import json, re + match = re.search(r'macOS datagram endpoint escaped scratch: (\{[^\n]+\})', text) + assert match, text + entry = json.loads(match[1]) + race = entry['observed']['datagram_race'] + assert race['host_control'] and race['settled'] and not race['failed'], entry + assert entry['datagram_receipts'] == 7 + race['host_acks'], entry + for token in ['"owner_received_fixture":true', '"acknowledged":true', + '"owner_datagram_received_fixture":true', '"private_datagram":[true,true,true]', + '"private_aliases":[true,true]', '"private_peer_matches":true']: + assert token in text, text + PYCONTROL + - name: Reject unknown datagram exchange failures + if: always() && !cancelled() && steps.native_setup.outcome == 'success' + shell: bash + run: | + set -euo pipefail + control="$RUNNER_TEMP/macos-datagram-bind-failure" + python3 - "$control" <<'PYCONTROL' + from pathlib import Path + import shutil, sys + target = Path(sys.argv[1]) + shutil.copytree('scripts/probes/plugin-confinement', target, ignore=shutil.ignore_patterns('evidence', '__pycache__')) + fixture = target / 'macos_datagram_race.h' + source = fixture.read_text() + needle = 'int client = datagram_socket(client_path);' + assert source.count(needle) == 1 + fixture.write_text(source.replace(needle, 'int client = datagram_socket(alias);')) + PYCONTROL + log="$RUNNER_TEMP/unix-datagram-bind-failure.log" + if node --experimental-strip-types "$control/macos_private_ipc.mts" > "$log" 2>&1; then + echo 'Occupied datagram client bind unexpectedly passed' >&2 + exit 1 + fi + python3 - "$log" <<'PYCONTROL' + from pathlib import Path + import errno, json, re, sys + text = Path(sys.argv[1]).read_text() + match = re.search(r'private datagram race control failed: (\{[^\n]+\})', text) + assert match, text + entry = json.loads(match[1]) + race = entry['observed']['datagram_race'] + assert entry['flags']['unrestricted'] and not race['private_control'], entry + assert race['failure_stage'] == 'client_bind', entry + assert race['failure_errno'] == errno.EADDRINUSE, entry + assert race['refused'] == 0 and race['attempts'] == 0, entry + diagnostic = entry['kernel_diagnostic'] + assert diagnostic['pid'] == entry['observed']['pid'] > 0, entry + assert len(diagnostic['output']) <= 16384 and len(diagnostic['stderr']) <= 2048, entry + PYCONTROL + - name: Reject missing private descriptor reception + if: always() && !cancelled() && steps.native_setup.outcome == 'success' + shell: bash + run: | + set -euo pipefail + log="$RUNNER_TEMP/unix-dropped-rights.log" + if node --experimental-strip-types scripts/probes/plugin-confinement/macos_private_ipc.mts --drop-rights > "$log" 2>&1; then + echo 'Missing SCM_RIGHTS reception unexpectedly passed' >&2 + exit 1 + fi + python3 - "$log" <<'PYCONTROL' + from pathlib import Path + import sys + text = Path(sys.argv[1]).read_text() + assert 'private SCM_RIGHTS exchange failed:' in text, text + for token in ['"original_closed":true', '"sent":true', '"token":true', + '"received":false', '"rights_payload":"A"', '"private_aliases":[true,true]']: + assert token in text, text + PYCONTROL + - name: Reject owner payload path re-resolution + if: always() && !cancelled() && steps.native_setup.outcome == 'success' + shell: bash + run: | + set -euo pipefail + control="$RUNNER_TEMP/macos-rights-path-reader" + python3 - "$control" <<'PYCONTROL' + from pathlib import Path + import shutil, sys + target = Path(sys.argv[1]) + shutil.copytree('scripts/probes/plugin-confinement', target, ignore=shutil.ignore_patterns('evidence', '__pycache__')) + driver = target / 'macos_private_ipc.mts' + text = driver.read_text() + before = ' const rights_payload = contents.subarray(0, count).toString();' + assert text.count(before) == 1 + driver.write_text(text.replace(before, ' const rights_payload = readFileSync(join(paths.scratch, "rights.bin"), "utf8");')) + PYCONTROL + log="$RUNNER_TEMP/unix-rights-path-reader.log" + if node --experimental-strip-types "$control/macos_private_ipc.mts" > "$log" 2>&1; then + echo 'Owner path re-resolution unexpectedly passed' >&2 + exit 1 + fi + python3 - "$log" <<'PYCONTROL' + from pathlib import Path + import sys + text = Path(sys.argv[1]).read_text() + assert 'received descriptor file effect:' in text, text + for token in ['"rights_path_replaced":true', '"rights_link_count":0', + '"rights_payload":"XX"', '"written":true', '"original_closed":true']: + assert token in text, text + PYCONTROL - name: Retain signing-identity candidate evidence if: always() uses: actions/upload-artifact@v4 @@ -156,6 +304,13 @@ jobs: path: | ${{ runner.temp }}/matrix.json ${{ runner.temp }}/matrix.log + ${{ runner.temp }}/private-unix-missing-grant.log + ${{ runner.temp }}/unix-boundary.json + ${{ runner.temp }}/unix-boundary.log + ${{ runner.temp }}/unix-missing-scope.log + ${{ runner.temp }}/unix-datagram-bind-failure.log + ${{ runner.temp }}/unix-dropped-rights.log + ${{ runner.temp }}/unix-rights-path-reader.log ${{ runner.temp }}/exec.json ${{ runner.temp }}/exec.log if-no-files-found: error @@ -197,7 +352,7 @@ jobs: if [[ "$runtime" == python-system ]]; then runtime=python fi - args=(--runtime "$runtime" --macos-signing-runtime --report "$RUNNER_TEMP/proof.json") + args=(--runtime "$runtime" --macos-signing-runtime --macos-private-unix --report "$RUNNER_TEMP/proof.json") if [[ "$PROOF_RUNTIME" == python ]]; then args+=(--python "$(command -v python3)") elif [[ "$PROOF_RUNTIME" == python-system ]]; then diff --git a/.github/workflows/plugin-linux-ipc.yml b/.github/workflows/plugin-linux-ipc.yml index abcd41e6c..2d00467f3 100644 --- a/.github/workflows/plugin-linux-ipc.yml +++ b/.github/workflows/plugin-linux-ipc.yml @@ -7,6 +7,8 @@ on: - crates/cyber-sandbox/tests/fixtures/plugin_unix_socket.c - crates/cyber-sandbox/tests/fixtures/plugin_socket_cancel.h - crates/cyber-sandbox/tests/fixtures/plugin_socket_mutation.h + - crates/cyber-sandbox/tests/fixtures/plugin_unix_mutation.h + - crates/cyber-sandbox/tests/fixtures/plugin_unix_relative.h - crates/cyber-sandbox/tests/fixtures/plugin_process_escape.h - crates/cyber-sandbox/tests/fixtures/plugin_socket_saturation.h - crates/cyber-sandbox/examples/support/plugin_socket_cancel.rs @@ -17,12 +19,16 @@ on: - .github/workflows/plugin-linux-ipc.yml - scripts/probes/plugin-native-entry/linux_socket_candidate.h - scripts/probes/plugin-native-entry/linux_socket_broker.h + - scripts/probes/plugin-native-entry/linux_socket_private_stream.h + - scripts/probes/plugin-native-entry/linux_socket_private_unix.h - scripts/probes/plugin-native-entry/linux_socket_broker_mutation.h - scripts/probes/plugin-native-entry/build_socket_candidate.py - scripts/probes/plugin-native-entry/linux_socket_landlock9.h - scripts/probes/plugin-native-entry/linux_unix9_policy.h - scripts/probes/plugin-native-entry/linux_pidfd_probe.c - scripts/probes/plugin-native-entry/pidfd.mts + - scripts/probes/plugin-native-entry/linux_unix_endpoint_pin.c + - scripts/probes/plugin-native-entry/unix-endpoint.mts permissions: contents: read concurrency: @@ -69,6 +75,20 @@ jobs: set -euo pipefail cc -Wall -Wextra -Werror -pthread scripts/probes/plugin-native-entry/linux_pidfd_probe.c -o "$RUNNER_TEMP/pidfd-probe" node --experimental-strip-types scripts/probes/plugin-native-entry/pidfd.mts --probe "$RUNNER_TEMP/pidfd-probe" --report "$RUNNER_TEMP/linux-pidfd.json" + - name: Observe pinned private UNIX endpoint identity + shell: bash + run: | + set -euo pipefail + cc -Wall -Wextra -Werror scripts/probes/plugin-native-entry/linux_unix_endpoint_pin.c -o "$RUNNER_TEMP/unix-endpoint-pin" + node --experimental-strip-types scripts/probes/plugin-native-entry/unix-endpoint.mts --probe "$RUNNER_TEMP/unix-endpoint-pin" --report "$RUNNER_TEMP/linux-unix-endpoint.json" + - name: Retain UNIX endpoint identity observations + if: always() + uses: actions/upload-artifact@v4 + with: + name: linux-unix-endpoint-${{ matrix.os }} + path: ${{ runner.temp }}/linux-unix-endpoint.json + if-no-files-found: warn + retention-days: 14 - name: Retain descriptor prerequisite observations if: always() uses: actions/upload-artifact@v4 @@ -89,6 +109,12 @@ jobs: python3 scripts/probes/plugin-native-entry/build_socket_candidate.py "$RUNNER_TEMP/socket-broker-audit" broker-audit python3 scripts/probes/plugin-native-entry/build_socket_candidate.py "$RUNNER_TEMP/socket-broker-no-cancel" broker-audit-no-cancel python3 scripts/probes/plugin-native-entry/build_socket_candidate.py "$RUNNER_TEMP/socket-broker-wrong-cap" broker-audit-wrong-cap + for variant in broker-private-stream broker-private-stream-no-makesock broker-private-stream-no-refer broker-private-stream-mutation broker-private-stream-mutation-no-endpoint; do + python3 scripts/probes/plugin-native-entry/build_socket_candidate.py "$RUNNER_TEMP/$variant" "$variant" + done + for variant in wrong-cwd no-mount no-unix-create no-alias; do + python3 scripts/probes/plugin-native-entry/build_socket_candidate.py "$RUNNER_TEMP/broker-private-stream-$variant" "broker-private-stream-$variant" + done for mode in mutation mutation-no-pin mutation-no-copy mutation-continue; do python3 scripts/probes/plugin-native-entry/build_socket_candidate.py "$RUNNER_TEMP/socket-broker-$mode" "broker-$mode" done @@ -106,6 +132,98 @@ jobs: target/debug/examples/plugin_unix_socket_probe "$RUNNER_TEMP/socket-broker-mutation" "$RUNNER_TEMP/linux-ipc-broker-mutation.json" --broker-mutation target/debug/examples/plugin_unix_socket_probe "$RUNNER_TEMP/socket-broker" "$RUNNER_TEMP/linux-ipc-broker-escape.json" --broker-escape target/debug/examples/plugin_unix_socket_probe "$RUNNER_TEMP/socket-broker-audit" "$RUNNER_TEMP/linux-ipc-broker-saturation.json" --broker-saturation + target/debug/examples/plugin_unix_socket_probe "$RUNNER_TEMP/broker-private-stream" "$RUNNER_TEMP/linux-ipc-broker-private-stream.json" --broker-private-stream + target/debug/examples/plugin_unix_socket_probe "$RUNNER_TEMP/broker-private-stream-mutation" "$RUNNER_TEMP/linux-ipc-broker-private-stream-mutation.json" --broker-private-stream-mutation + - name: Record accepted-side kernel peer identity compatibility + shell: bash + run: | + set -euo pipefail + python3 - "$RUNNER_TEMP/linux-ipc-broker-private-stream.json" <<'PY' + import json + import sys + report = json.load(open(sys.argv[1])) + rows = [row for row in report['rows'] if row.get('mode') == 'peer-identity'] + assert len(rows) == 4, rows + for row in rows: + control, observed = row['unconfined_control'], row['observed'] + assert control['exchanged'] and control['peer_checks'] == 1, row + assert control['peer_matches'] and control['client_peer_matches'], row + assert control['accepted_pid'] == control['expected_pid'], row + assert observed['exchanged'] and observed['peer_checks'] == 1, row + assert observed['client_peer_matches'], row + assert observed['accepted_pid'] != observed['expected_pid'], row + assert not observed['peer_matches'] and not row['compatibility_accepted'], row + print('Recorded accepted-side peer PID mismatch in all four capability combinations; full IPC acceptance remains open') + PY + - name: Reject pathname re-resolution after private endpoint pinning + shell: bash + run: | + set -euo pipefail + log="$RUNNER_TEMP/private-stream-no-endpoint.log" + if target/debug/examples/plugin_unix_socket_probe "$RUNNER_TEMP/broker-private-stream-mutation-no-endpoint" "$RUNNER_TEMP/linux-ipc-private-no-endpoint.json" --broker-private-stream-mutation > "$log" 2>&1; then + echo 'Missing private endpoint pin unexpectedly passed' >&2 + exit 1 + fi + python3 - "$log" <<'PY' + from pathlib import Path + import sys + text = Path(sys.argv[1]).read_text() + assert 'private UNIX mutation escaped the pinned endpoint' in text, text + for token in ['"fd_replaced":true', '"address_changed":true', '"path_replaced":true', + '"original_unlinked":true', '"result_ok":true', '"sent":true', + '"private_roundtrip":false', '"host_received_fixture":true', 'BPIN ']: + assert token in text, text + PY + - name: Reject wrong relative context and missing endpoint mount boundary + shell: bash + run: | + set -euo pipefail + for control in wrong-cwd no-mount no-alias; do + log="$RUNNER_TEMP/private-stream-$control.log" + if target/debug/examples/plugin_unix_socket_probe "$RUNNER_TEMP/broker-private-stream-$control" "$RUNNER_TEMP/linux-ipc-private-$control.json" --broker-private-stream > "$log" 2>&1; then + echo "Private relative-path control unexpectedly passed: $control" >&2 + exit 1 + fi + python3 - "$log" "$control" <<'PY' + from pathlib import Path + import sys + text = Path(sys.argv[1]).read_text() + if sys.argv[2] == 'wrong-cwd': + assert 'private relative stream exchange: relative_root_roundtrip' in text, text + assert '"scratch_unix_roundtrip":true' in text and '"relative_root_roundtrip":false' in text, text + elif sys.argv[2] == 'no-mount': + assert 'socket denial: outside:' in text, text + for token in ['"host_received_fixture":true', '"connected":true', '"sent":true']: + assert token in text, text + else: + assert 'private relative stream exchange: alias_relative_roundtrip' in text, text + for token in ['"scratch_unix_roundtrip":true', '"relative_root_roundtrip":true', + '"outside_cwd_roundtrip":true', '"alias_relative_roundtrip":false']: + assert token in text, text + PY + done + - name: Reject missing private socket creation and file-move compatibility guards + shell: bash + run: | + set -euo pipefail + for control in no-makesock no-refer; do + log="$RUNNER_TEMP/broker-private-stream-$control.log" + if target/debug/examples/plugin_unix_socket_probe "$RUNNER_TEMP/broker-private-stream-$control" "$RUNNER_TEMP/linux-ipc-private-stream-$control.json" --broker-private-stream > "$log" 2>&1; then + echo "Private stream control unexpectedly passed: $control" >&2 + exit 1 + fi + python3 - "$log" "$control" <<'PY' + from pathlib import Path + import sys + text = Path(sys.argv[1]).read_text() + if sys.argv[2] == 'no-makesock': + assert 'private pair escape: pair_bind: public read=false write=true' in text, text + assert '"pair_bind":true' in text, text + else: + assert 'private IPC layer changed ordinary file moves:' in text, text + assert 'write=true' in text and '"regular_reparent":false' in text, text + PY + done - name: Reject blanket bind denial as broker acceptance shell: bash run: | @@ -283,7 +401,7 @@ jobs: set -euo pipefail mkdir -p "$RUNNER_TEMP/socket-runtime" status=0 - for policy in seccomp broker unix9; do + for policy in seccomp broker private-stream unix9; do if [[ "$policy" == unix9 && '${{ steps.abi.outputs.supported }}' != true ]]; then echo 'UNVERIFIED: skipping ABI-9 runtime matrix on this unsupported kernel.' continue @@ -291,10 +409,12 @@ jobs: mkdir -p "$RUNNER_TEMP/socket-runtime/$policy" helper="$RUNNER_TEMP/socket-candidate" if [[ "$policy" == broker ]]; then helper="$RUNNER_TEMP/socket-broker"; fi + if [[ "$policy" == private-stream ]]; then helper="$RUNNER_TEMP/broker-private-stream"; fi if [[ "$policy" == unix9 ]]; then helper="$RUNNER_TEMP/unix9-candidate"; fi for runtime in node python bun deno native; do args=(--runtime "$runtime" --initial-exec --packaged-helper "$helper" --report "$RUNNER_TEMP/socket-runtime/$policy/$runtime.json" --trace "$RUNNER_TEMP/socket-runtime/$policy/trace-$runtime") if [[ "$policy" == broker ]]; then args+=(--socket-broker); fi + if [[ "$policy" == private-stream ]]; then args+=(--socket-broker --broker-private-stream); fi if [[ "$policy" == unix9 ]]; then args+=(--unix-scratch-policy); fi if [[ "$runtime" == python ]]; then args+=(--python "$(command -v python3)") @@ -308,6 +428,22 @@ jobs: done done exit "$status" + - name: Reject missing runtime private pathname socket support + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + shell: bash + run: | + set -euo pipefail + log="$RUNNER_TEMP/private-runtime-no-create.log" + if node --experimental-strip-types scripts/probes/plugin-confinement/run.mts --runtime python --python "$(command -v python3)" --initial-exec --packaged-helper "$RUNNER_TEMP/broker-private-stream-no-unix-create" --socket-broker --broker-private-stream --report "$RUNNER_TEMP/private-runtime-no-create.json" > "$log" 2>&1; then + echo 'Missing private socket creation unexpectedly passed runtime acceptance' >&2 + exit 1 + fi + python3 - "$log" <<'PY' + from pathlib import Path + import sys + text = Path(sys.argv[1]).read_text() + assert 'no-capabilities: private UNIX runtime exchange failed: [false,false]' in text, text + PY - name: Retain diagnostic observations if: always() uses: actions/upload-artifact@v4 @@ -319,6 +455,13 @@ jobs: ${{ runner.temp }}/unix9-missing-resolve.log ${{ runner.temp }}/unix9-unavailable.log ${{ runner.temp }}/broker-no-bind.log + ${{ runner.temp }}/broker-private-stream-no-makesock.log + ${{ runner.temp }}/broker-private-stream-no-refer.log + ${{ runner.temp }}/private-stream-no-endpoint.log + ${{ runner.temp }}/private-stream-wrong-cwd.log + ${{ runner.temp }}/private-stream-no-mount.log + ${{ runner.temp }}/private-stream-no-alias.log + ${{ runner.temp }}/private-runtime-no-create.log ${{ runner.temp }}/broker-no-thread.log ${{ runner.temp }}/broker-no-cancel.log ${{ runner.temp }}/broker-mutation-*.log diff --git a/.github/workflows/plugin-native-launcher.yml b/.github/workflows/plugin-native-launcher.yml index ee207230b..d63949132 100644 --- a/.github/workflows/plugin-native-launcher.yml +++ b/.github/workflows/plugin-native-launcher.yml @@ -209,7 +209,7 @@ jobs: if [[ "$RUNNER_OS" == Linux ]]; then args+=(--initial-exec --trace "$RUNNER_TEMP/packaged-runtime/trace-$runtime") else - args+=(--macos-signing-runtime) + args+=(--macos-signing-runtime --macos-private-unix) fi if ! node --experimental-strip-types scripts/probes/plugin-confinement/run.mts "${args[@]}" > "$RUNNER_TEMP/packaged-runtime/$runtime.log" 2>&1; then cat "$RUNNER_TEMP/packaged-runtime/$runtime.log" @@ -217,7 +217,7 @@ jobs: fi done if [[ "$RUNNER_OS" == macOS ]]; then - if ! node --experimental-strip-types scripts/probes/plugin-confinement/run.mts --runtime python --python /usr/bin/python3 --macos-signing-runtime --packaged-helper "$helper" --report "$RUNNER_TEMP/packaged-runtime/python-system.json" > "$RUNNER_TEMP/packaged-runtime/python-system.log" 2>&1; then + if ! node --experimental-strip-types scripts/probes/plugin-confinement/run.mts --runtime python --python /usr/bin/python3 --macos-signing-runtime --macos-private-unix --packaged-helper "$helper" --report "$RUNNER_TEMP/packaged-runtime/python-system.json" > "$RUNNER_TEMP/packaged-runtime/python-system.log" 2>&1; then cat "$RUNNER_TEMP/packaged-runtime/python-system.log" matrix_status=1 fi diff --git a/ROADMAP.md b/ROADMAP.md index 65c2da81e..a6c1b4745 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -62,7 +62,7 @@ The first release prioritizes reliable editing, recovery and measurable coding q **Milestones** 1. **M1.1 Sandbox + modes:** **Status: in progress** (change `implement-m1-1-sandbox-modes`; implementation authorized while P0 local-model validation continues). **Acceptance (2026-10-18):** 15 of 18 P1 requirements done locally ([acceptance plan](docs/implementation/p1-acceptance-plan.md): 15 a, 2 c, 1 e; reclassified 2026-10-23). Increments 6 and 8 (profiles, environment policy, escalation, excluded commands, `sandbox.*` events, container detection, the `bypass` gate, the sandbox CLI, rule dry run, org `modes.disable`, the mode rows and the tool catalog) were demonstrated on macOS in batch B, including the escape exit criterion; Increment 15 (2026-10-18) routes Windows production dispatch through `cyber-sandbox-exec --appcontainer` (recursive roots with exclusions, grants inherited by new files, `internetClient` only for `network: on`) and finishes the PowerShell analysis (startup and parameter abbreviations, aliases, more mutating cmdlets and .NET file members); Windows enforcement and the `powershell` tool are partial (c): the AppContainer launch, ACL and isolation layers pass native Windows CI, but PowerShell 7 and Git Bash cannot start inside the AppContainer ([issue #9](https://github.com/CyberdyneCorp/cybercode-cli/issues/9)), so their native shell tests are ignored on Windows, and the Windows allowlist proxy is still open (`proxy` confines like `off`). The live auto classifier (external evidence) remains. The automated suites run on Linux (bubblewrap, including the escape exit criterion) and on native Windows in main CI ([evidence pass](docs/implementation/p1-signoff-plan.md), 2026-10-23); the manual batch scenarios ran on macOS only. The history below predates that sign-off. Extend the P0 Seatbelt / bubblewrap baseline with Windows restricted tokens and Windows network enforcement through the existing allowlist proxy; mode cycling; the auto-mode classifier backed by `model_roles.evaluator`. The evaluator foundation, Bash and initial Python/JavaScript removal guards and four-mode TUI/effective-mode state are implemented; Production auto-mode tool dispatch is implemented locally with durable allow/block/fallback decisions and preserved manual ceilings; Validated trusted auto-mode rules/policy/read-only/fallback controls are implemented locally with project trust gating and durable policy decisions; Checkout-scoped statistics/reset, confirmed one-shot replay and ancestor auto block ceilings are implemented locally. Ancestor classifier/config gates are implemented locally with parent-specific context and child billing. Literal filesystem path analysis covers file operands, directory copies/moves and implicit destinations locally, including descendant, alias and inventory checks; native acceptance and remaining option/context semantics remain open. Native AppContainer asynchronous streams and the foreground process adapter pass at `fcebb4f`; remaining interpreter proofs and complete Windows enforcement remain open. 2. **M1.2 Subagents done right:** **Status: in progress** (change `implement-m1-2-subagents-worktrees`). **Acceptance (2026-10-18):** 41 of 41 P1 requirements done locally (41 a): all 14 agents-subagents and all 9 worktrees rows, demonstrated on the real binary and server in batch A, and all 15 background-tasks rows (process Jobs, monitor, PTY, notify, send file), the `monitor` tool and the TUI tasks view and side chat (increments 7–9), demonstrated on macOS in batch B. The automated suites run on Linux and native Windows in main CI (2026-10-23 [evidence pass](docs/implementation/p1-signoff-plan.md): long-root worktree inspection and creation/reuse and cyclic-graph deletion now pass natively); the PTY tools are Unix-only and isolated-child shells on Windows are blocked by issue #9, so the milestone is not signed off natively. The history below predates that sign-off. Durable child result ownership is exclusive across runtimes and retains original target boundaries through callback and writer admission. Owned local subtree stop and idle-native cancellation are implemented with bounded acknowledgement and durable Unknown reports; Matched reopening is implemented locally with native acceptance pending; reviewed recovery, complete cross-process actor cancellation and client adoption remain open. Managed creation/reuse, primary-checkout inclusion, repository locking and journaled setup preserve user edits and interruption evidence. TUI/exec `--worktree` and API POST start fresh Sessions with live setup output. `cyber worktree list`, API GET and SDK listing report verified Git status and associated Sessions, with pending/invalid recovery diagnostics. Session creation, Drains, user shell, rewind, compaction, context repair and Session setup now claim cross-process checkout leases and persist exact creation bindings; crashed/disposed work retains unknown activity. Native Windows startup admission, runtime capability verification and low-level LPAC launch/isolation steps pass at `44f95be`; complete enforced execution remains unaccepted. Windows long-root status, summaries and ignored enumeration route through an owned read-only helper; repository, helper and actual isolated-child native steps and the full CI matrix pass at 3d2ea39. Completed Windows CI at `af9d018` passes the corrected shell fixture and model-selection coverage and fails only the mandatory long-root status assertion. All other jobs pass; newer Mode/step and foreground/structured child work still requires native acceptance. Agent model/variant defaults, starting Mode, step limits and identity pinning through preparation/tool settlement are implemented locally; fresh foreground and background children now execute with permission-before-creation, cancellation ownership, depth checks and in-process FIFO admission, including schema-governed results with one correction attempt. Foreground/background children share durable parent-scoped names. Background tasks have durable records, restart interruption, queue handback and API/SDK/TUI listing/stop controls; local children can resume by name/ID with exclusive result ownership and fresh structured attempts. Local forked children inherit history, Epoch and task context without copied execution ownership, and /fork creates an independent top-level Session. The /subtask command and authenticated API/SDK start explicit user-requested background forks while keeping the parent open. The endpoint also accepts visible named subagent profiles for fresh background children; leading TUI @mentions and agent/file completion use it. Exec leading mentions now use typed delegation with attachments, profile-capped step limits, durable child history, terminal Job following and owned cancellation. Caller-owned runtime cancellation preserves queued noninterference and settles a racing owned Job handoff. An input-bound durable admission ledger now exposes authenticated API/SDK start, lookup and stop, with pre-submission tombstones, a pre-effect launch marker and fenced unknown outcomes. Exec now retains a client request ID before submission, recovers lost responses through scoped lookup and applies its timeout/interruption to queued admission; 815 local Rust tests and workspace Clippy pass. The TUI now saves request IDs before submission, restores lookup on reconnect and offers pending-request cancellation through Esc and `/admissions`; local validation passes 57 TUI tests and workspace Clippy. Explicit cancellation now resolves abandoned reserved admissions through the durable launch fence and preserves tombstones against late host errors; 835 local Rust test executions and workspace Clippy pass, including abrupt owner-death coverage. Complete native-effect reconciliation remains required. Descendant billing now persists nested and hidden usage separately from own totals, retains surviving ancestor charges after child deletion and discloses incomplete historical backfill. Local validation passes 843 Rust executions and workspace Clippy; native acceptance is pending. Delegated exec now checks combined own/descendant cost and tokens at the recorded limit, negotiates billing capability before submission and preserves separate totals in JSON; 52 CLI test executions and workspace Clippy pass locally. Soft Session budgets now persist trusted creation defaults, activation and warning/exhaustion markers; visible and hidden provider calls, retries and web summaries check every ancestor against durable own/descendant spending. Local validation passes 870 Rust executions, followed by 57 budget/compaction/HTTP tests and workspace Clippy after error/type refinements, plus 47 SDK tests and strict specs. Native acceptance is pending. Typed budget/storage refusals now survive compaction preparation and retries; two unchanged-main regressions and all 59 focused tests plus workspace Clippy pass locally. Background Job settlement now includes durable descendant deltas with resume baselines and legacy uncertainty; 65 focused tests and workspace Clippy pass locally. Ordinary exec now observes durable subtree deltas, excludes prior history, reports attribution uncertainty and bounds interruption acknowledgement; 59 CLI tests and workspace Clippy pass locally. Descendant token-class receipts now survive deletion and expose separate class completeness in the API/SDK; 88 focused server/storage tests and workspace Clippy pass locally, with native acceptance and full cost presentation pending. TUI `/cost` now displays a refreshable subtree snapshot with token classes, cache hit rate and attribution uncertainty; all 61 TUI tests and workspace Clippy pass locally. The Session usage API and SDK now read own/descendant/combined billing from one SQL snapshot with corruption refusal and completeness flags; 43 focused server tests and 48 SDK tests pass locally. TUI cost refresh now uses the atomic usage API with strict report validation and stale-response fencing; 64 TUI tests and workspace Clippy pass locally. Completed native diagnostic CI at `553f718` still fails mandatory long-root status: staged diff succeeds, while unstaged/untracked controls fail, so a composed-command replacement is not accepted. A test-only library status candidate now covers long roots and read-only repository evidence; native results and owned sandbox integration remain pending, with production status unchanged. Generic durable prompt admission, explicit subtree cancellation with racing-admission fences, complete live client monitoring and queued/held delegation remain open. Reserved all-scope request budgets and daily accounting are now implemented for bounded official OpenAI Responses requests; see [current bounds and limits](docs/reserved-budgets.md). Fresh isolated foreground/background children, including forks and structured results, now use durable managed bindings, separate branches, source-authorized setup and file/diff summaries. Clean child removal uses checkout activity fencing; edits, ahead commits, keep policies and failed/cancelled work are retained. Retained children resume by identity after restart. Cleanup confirmation now uses child-owned routed requests, with late-policy/edit checks and cancellation cleanup. Explicit user isolated profiles now create and set up checkouts in all six Modes without widening child tool permissions; source/ancestor denies and read-only sandbox policy still refuse creation. Git-valid child names use the Session naming limit independently of storage names. Acknowledged clean removals now recreate with a new durable child binding and setup-ready gate, preserving the original base, history and branch; missing/foreign/force/incomplete evidence refuses recreation. Existing primary forks and user subtasks resume without granting fresh primary spawning. Parent-scoped API/SDK setup inspection and explicit retry now preserve successful steps, require a reviewed journal revision/recipe and fence late acknowledgements; completed journals can reconcile missing readiness without inference. Verified pre-launch preparation failures are recorded distinctly and support reviewed retry; generic/launch errors and missing acknowledgements remain unknown. Session/history streams now follow the durable Location timeline; old/new listeners receive rebound notices and the TUI applies the new action scope without allowing stale snapshots to restore the old path. Native acceptance, unknown-outcome recovery resolution and full profile configuration snapshots remain open. Child-thread API/SDK listing and TUI `/agent`/`/subagents` browsing now have local pagination, status, parent navigation and active-child steer coverage; idle child prompts now reset structured attempts atomically under exclusive execution ownership, with result-owner refusal and replay coverage; public continuation now recreates acknowledged clean removals, gates inference on source setup and owns cleanup/reporting through approval, interruption and shutdown. Local tests cover all Modes, denies, read-only refusal, edit preservation and durable unknown-settlement refusal. Explicit child inbox wake and held release now prepare verified checkouts and reset attempts atomically while preserving original input identity, edits and queue order; API/SDK wake is available. Requested child input now hands off between structured attempts after foreground collection or durable Job result/billing settlement; interruption pauses automatic intent without deleting rows. Existing-child preparation is now owned through cancellation and admission, with a durable interruption boundary, no late input/release and replay refusal for unacknowledged effects; native tests cover interrupt, disposal and shutdown locally. Fresh child launches now capture source/ancestor authority before approval/FIFO, verify native creation retries and recheck durable bindings in the writer; source interruption and explicit ancestor fences reject stale creation without child history. Delegation and owned child-preparation callbacks now retain captured authority through native awaits; writer fences cover reservation/launch, scoped input/reset and Job handoff, including request cancellation during Location admission. Missing owners cannot advance Reserved launch markers, and registered Jobs preserve ordinary parent-interrupt independence. Direct subtask callbacks now share tracked durable ownership, retain native cancellation acknowledgement after caller disposal and require reply acceptance before transferring Job ownership. Rejected handoffs stop verified Jobs; missing cancellation ownership records durable Unknown and refuses successful cancellation. Durable scope closure now refuses fresh capture and unbound writer admission across closed source/target ancestry, survives restart and later generation fences, and gates native Location waits while preserving terminal Job and shell receipts. The complete bounded actor sweep, tracked idle-native cancellation, matched reopening, detached/cross-process ownership, generic prompt admission, recovery resolution and native acceptance remain open. Public removal/prune, client attachment and process-death recovery, automatic cleanup, snapshot-safe enter/exit, recovery resolution, TUI output/artifacts and complete subagent thread continuation remain open. -3. **M1.3 Extensibility:** **Status: in progress** (change `implement-m1-3-extensibility`). **Acceptance (2026-10-18):** 48 of 53 P1 requirements done locally (48 a, 3 c, 2 e): every hooks row except Supported events, MCP (OAuth needs only native keyring evidence), SDK hook callbacks, org MCP controls, skills and commands, and VCS text, demonstrated on the real binary, server and SDK in batch A; since increment 12 the declarative plugin foundation (manifest, discovery defaults, scopes and lockfile, path/git/npm sources, `cyber plugins`, `/plugins`, digest trust, `--pure`) and the plugin hook scope; since increment 13 the sandboxed JSON-RPC plugin host (forwarded and deferred tools, crash isolation and backoff, scoped registrations, network and write capabilities, validated settings with keyring secrets) and the `@cyber-code/plugin` TypeScript kit; since increment 15 command-hook sandboxing (project and plugin hooks write only inside the workspace). Increments 12, 13 and 15 were demonstrated on macOS in batch C (real binary, `cyber serve`, Python and Node plugin hosts, local git and npm sources, TUI `/plugins`). Host-protocol hooks, providers and `session/prompt`, `fs.read`/`shell` enforcement, native keyring evidence for plugin secrets and the missing hook events remain; the Windows hook sandbox test is ignored on Windows because PowerShell 7 cannot start inside the AppContainer (issue #9); the remote MCP suites now pass on native Windows, while the local stdio MCP cases are Unix-only (2026-10-23 [evidence pass](docs/implementation/p1-signoff-plan.md)). The history below predates that sign-off. Typed hook configuration/event/handler validation is implemented locally with selector, timeout and checkout-trust tests. Loaded global/project/local hook groups now accumulate at top level and within selected profiles, retaining indexed file origins; managed/plugin collection, final execution scope order and managed-only authority remain open. Canonical handler digests, separate durable approvals in the shared checkout trust store and temporary invocation approval sets are implemented locally with cross-process storage tests. The scope-aware catalog orders resolved contributions and exposes uncached trust/sandbox requirements; CLI list/trust/untrust supports exact-digest review and obsolete revocation. Compiled subject/path/condition selectors, event-aware ordered decision parsing/merging and immutable envelope assembly are implemented locally; built-in tool command dispatch now captures invocation identity, chains rewrites and validates final input before permissions. Remaining lifecycle identity/emission, full target extraction, effectful scheduling and other handler types remain open. Owned command stream capture now supports event stdin, bounded output and explicit stop acknowledgement locally. Command result interpretation has local contract and real-process tests for JSON decisions, exit codes, timeout policy and unknown termination; Noninteractive Unix command launch has local trust-revocation, event-input/environment, mandatory sandbox and scratch-ownership tests. Recorded Unix command execution now has local durable admission/receipt, privacy and tracked subtree cancellation tests. Unix built-in PreToolUse/PostToolUse/PostToolUseFailure command dispatch now has local denial, rewrite, permission, trust and receipt tests, plus transient TUI/exec notices. Command PermissionRequest hooks now answer individual asks without saved approvals and retain hard denials/Mode limits. Fixed-input request/post events now use bounded command concurrency with immediate slot reuse, declared-order merging and acknowledged sibling cancellation; rewrite-dependent pre-tool scheduling remains open. Command once-per-Session claims now persist atomically with receipt admission and survive concurrent runtimes, restart and unknown settlement; status/completion messages are user-only notices. Authenticated Session receipt pagination, generated SDK access, direct-store CLI history and TUI receipt history now expose durable outcomes, pinned IO policy and optional call/tool correlation without claiming unresolved processes are live. Eligible global Windows full-access hooks now have a parent-owned event-input route with native acceptance pending. Required Windows hook confinement, other lifecycle/remote-tool dispatch, recovery reconciliation and complete client observability remain open. Authenticated Location definition catalog access and generated SDK `hook.list` now provide fresh trust metadata with redacted credentials and original digests. TUI loaded-definition and exact trust review now have local render/state/authenticated HTTP tests. Raw withheld top-level/profile section review is implemented without substitution or handler interpretation across API/SDK/CLI/TUI. Exact effective-digest/event/scope checkout last-run summaries are implemented across API/SDK/CLI/TUI without raw IO or recovery effects. Independent durable synthetic receipts and protected event construction are implemented without Session effects. Synthetic object payloads cannot replace the generated invocation identity or trusted envelope fields. Owned command test execution now uses independent receipts and receipt-specific managed checkout leases, with once skipping, acknowledged cancellation, unknown disposal fencing and optional bounded IO; `cyber hooks test [--payload FILE]` now exercises command handlers through bounded payload validation, event selectors, rewrite chaining, fixed-event concurrency/deduplication and ordered decisions without Session startup. Synchronous HTTP POST hooks now share durable Session/synthetic ownership, strict bounded decisions, scoped network policy and acknowledged local proxy shutdown; Prompt handlers now use tool-free evaluator/small-model judgement and hidden Session/ancestor billing; Native HTTP provider adapters now acknowledge owned local proxy shutdown and honor nonblocking/fail-closed timeout policy; remote processing/final billing remain unverified and custom-adapter/native acceptance remain open. Configured local MCP launch and initial paginated discovery are implemented locally, with stable names, filters, exposed dispatch and explicit stale-tool refresh. Independent durable MCP ownership and the owned local adapter now retain complete managed checkout pins, unresolved disposal evidence and retryable database settlement. Shared concurrent local startup, runtime tool registration/permissions/hooks and shutdown are now implemented locally, including exact registration identities and managed checkout pins. Application client tools now share schema, permission, hook and bounded output settlement with MCP, and public tool listing matches scoped runtime visibility. Location-scoped host close now fences admission, settles retained native owners and permits fresh registration only after verified closure; unknown startup remains fenced. Authenticated HTTP and generated SDK close are now implemented with scoped replay identity and actual-process tests. Committed local status events now reach the instance stream with independent connection identity and redacted SDK payloads. Authenticated configured status snapshots now combine fresh definitions and retained observations without startup; stored owners are not adopted as live actors. Retained idle monitors now detect local leader exit and ready stdio EOF, remove discovery and explicitly settle native ownership. Bounded local notification processing and authorized list-change refresh are implemented locally, with metadata-bound stale registrations and retained cancellation cleanup. Owned startup failures now distinguish complete retry-safe settlement from native acknowledgement, with explicit prepared-proxy cleanup; retained local reconnect now uses fresh authorization/identities, cancellable 1–60 second backoff with ten-attempt limits and no call replay. The read-only wait_for_mcp tool now waits up to sixty seconds for named connecting servers through normal permission/hook/cancellation boundaries, with golden and actual delayed-startup coverage. Explicit registration scopes and a shared deferred-catalog materializer now validate the sole threshold, preserve client precedence and separate schema-free summaries; Session-persistent tool_search and runtime schema projection are now implemented locally, with unloaded refusal, durable replay/compaction and real MCP search/use coverage; plugin delivery and native acceptance remain open. Captured local roots now advertise the Location and configured writable paths with bounded roots/list callbacks during initialization, calls and idle processing; sampling remains open. Local server instructions now enter the scoped mcp/instructions Context Source, with registration-aware filtering, client shadowing and explicit replacement/withdrawal system messages; remote/native acceptance remains open. Required local servers now gate the first Turn before prompt promotion with named errors and retryable inbox inputs; cancellation preserves startup ownership, and replay/rewind preserve first-Turn completion. Graceful POSIX Location/runtime close now retains group identity through SIGTERM and five seconds before SIGKILL; interrupted close retains its deadline, while failed startup/cancelled calls keep immediate cleanup. Explicit call-timeout overrides now use approval-bound presence and global fallback; only valid advancing progress extends request inactivity. Per-server estimated output caps and side-effect-free cyber mcp get resolved review are implemented locally; dynamic header commands/refresh and remote/native acceptance remain open. Withheld definitions and remaining status transitions, CLI/TUI close, reconfiguration, remote reconnect and MCP transport and async scheduling remain incomplete; complete pre-event scheduling, other transports and native acceptance remain open. Exec trust integration, all remaining lifecycle events and client controls, JSON-RPC plugin host + `@cyber-code/plugin`, MCP OAuth and complete native acceptance remain open. Local stdio form elicitation now has call-owned typed questions and explicit consent with cancellation/revocation tests; 229 local Rust cases, workspace Clippy and SDK/spec checks pass. Remote/URL elicitation and native acceptance remain pending. Opt-in local sampling now uses small-model permission/budget gates and server-attributed usage, with actual native-provider timeout/close and budget tests; full platform and remote acceptance remain open. Synchronous local MCP-tool hooks now own dedicated connections through fresh trust, filtered/schema-checked calls, absolute deadlines, cancellation and durable receipt cleanup. Actual callback and CLI tests pass; remote hooks, async scheduling and full platform acceptance remain open. +3. **M1.3 Extensibility:** **Status: in progress** (change `implement-m1-3-extensibility`). **Acceptance (2026-10-18):** 48 of 53 P1 requirements done locally (48 a, 3 c, 2 e): every hooks row except Supported events, MCP (OAuth needs only native keyring evidence), SDK hook callbacks, org MCP controls, skills and commands, and VCS text, demonstrated on the real binary, server and SDK in batch A; since increment 12 the declarative plugin foundation (manifest, discovery defaults, scopes and lockfile, path/git/npm sources, `cyber plugins`, `/plugins`, digest trust, `--pure`) and the plugin hook scope; since increment 13 the sandboxed JSON-RPC plugin host (forwarded and deferred tools, crash isolation and backoff, scoped registrations, network and write capabilities, validated settings with keyring secrets) and the `@cyber-code/plugin` TypeScript kit; since increment 15 command-hook sandboxing (project and plugin hooks write only inside the workspace). Increments 12, 13 and 15 were demonstrated on macOS in batch C (real binary, `cyber serve`, Python and Node plugin hosts, local git and npm sources, TUI `/plugins`). Host-protocol hooks, providers, catalog transforms and `session/prompt`, `fs.read`/`shell` enforcement, native keyring evidence for plugin secrets, `DirectoryAdded`, explicit `Setup` initialization/marker semantics and combined hook-producer acceptance remain. Committed `CwdChanged` and bounded `FileChanged` observation have producers; FileChanged resource/lifecycle acceptance remains open; the Windows hook sandbox test is ignored on Windows because PowerShell 7 cannot start inside the AppContainer (issue #9); the remote MCP suites now pass on native Windows, while the local stdio MCP cases are Unix-only (2026-10-23 [evidence pass](docs/implementation/p1-signoff-plan.md)). The history below predates that sign-off. Typed hook configuration/event/handler validation is implemented locally with selector, timeout and checkout-trust tests. Loaded global/project/local hook groups now accumulate at top level and within selected profiles, retaining indexed file origins; managed/plugin collection, final execution scope order and managed-only authority remain open. Canonical handler digests, separate durable approvals in the shared checkout trust store and temporary invocation approval sets are implemented locally with cross-process storage tests. The scope-aware catalog orders resolved contributions and exposes uncached trust/sandbox requirements; CLI list/trust/untrust supports exact-digest review and obsolete revocation. Compiled subject/path/condition selectors, event-aware ordered decision parsing/merging and immutable envelope assembly are implemented locally; built-in tool command dispatch now captures invocation identity, chains rewrites and validates final input before permissions. Remaining lifecycle identity/emission, full target extraction, effectful scheduling and other handler types remain open. Owned command stream capture now supports event stdin, bounded output and explicit stop acknowledgement locally. Command result interpretation has local contract and real-process tests for JSON decisions, exit codes, timeout policy and unknown termination; Noninteractive Unix command launch has local trust-revocation, event-input/environment, mandatory sandbox and scratch-ownership tests. Recorded Unix command execution now has local durable admission/receipt, privacy and tracked subtree cancellation tests. Unix built-in PreToolUse/PostToolUse/PostToolUseFailure command dispatch now has local denial, rewrite, permission, trust and receipt tests, plus transient TUI/exec notices. Command PermissionRequest hooks now answer individual asks without saved approvals and retain hard denials/Mode limits. Fixed-input request/post events now use bounded command concurrency with immediate slot reuse, declared-order merging and acknowledged sibling cancellation; rewrite-dependent pre-tool scheduling remains open. Command once-per-Session claims now persist atomically with receipt admission and survive concurrent runtimes, restart and unknown settlement; status/completion messages are user-only notices. Authenticated Session receipt pagination, generated SDK access, direct-store CLI history and TUI receipt history now expose durable outcomes, pinned IO policy and optional call/tool correlation without claiming unresolved processes are live. Eligible global Windows full-access hooks now have a parent-owned event-input route with native acceptance pending. Required Windows hook confinement, other lifecycle/remote-tool dispatch, recovery reconciliation and complete client observability remain open. Authenticated Location definition catalog access and generated SDK `hook.list` now provide fresh trust metadata with redacted credentials and original digests. TUI loaded-definition and exact trust review now have local render/state/authenticated HTTP tests. Raw withheld top-level/profile section review is implemented without substitution or handler interpretation across API/SDK/CLI/TUI. Exact effective-digest/event/scope checkout last-run summaries are implemented across API/SDK/CLI/TUI without raw IO or recovery effects. Independent durable synthetic receipts and protected event construction are implemented without Session effects. Synthetic object payloads cannot replace the generated invocation identity or trusted envelope fields. Owned command test execution now uses independent receipts and receipt-specific managed checkout leases, with once skipping, acknowledged cancellation, unknown disposal fencing and optional bounded IO; `cyber hooks test [--payload FILE]` now exercises command handlers through bounded payload validation, event selectors, rewrite chaining, fixed-event concurrency/deduplication and ordered decisions without Session startup. Synchronous HTTP POST hooks now share durable Session/synthetic ownership, strict bounded decisions, scoped network policy and acknowledged local proxy shutdown; Prompt handlers now use tool-free evaluator/small-model judgement and hidden Session/ancestor billing; Native HTTP provider adapters now acknowledge owned local proxy shutdown and honor nonblocking/fail-closed timeout policy; remote processing/final billing remain unverified and custom-adapter/native acceptance remain open. Configured local MCP launch and initial paginated discovery are implemented locally, with stable names, filters, exposed dispatch and explicit stale-tool refresh. Independent durable MCP ownership and the owned local adapter now retain complete managed checkout pins, unresolved disposal evidence and retryable database settlement. Shared concurrent local startup, runtime tool registration/permissions/hooks and shutdown are now implemented locally, including exact registration identities and managed checkout pins. Application client tools now share schema, permission, hook and bounded output settlement with MCP, and public tool listing matches scoped runtime visibility. Location-scoped host close now fences admission, settles retained native owners and permits fresh registration only after verified closure; unknown startup remains fenced. Authenticated HTTP and generated SDK close are now implemented with scoped replay identity and actual-process tests. Committed local status events now reach the instance stream with independent connection identity and redacted SDK payloads. Authenticated configured status snapshots now combine fresh definitions and retained observations without startup; stored owners are not adopted as live actors. Retained idle monitors now detect local leader exit and ready stdio EOF, remove discovery and explicitly settle native ownership. Bounded local notification processing and authorized list-change refresh are implemented locally, with metadata-bound stale registrations and retained cancellation cleanup. Owned startup failures now distinguish complete retry-safe settlement from native acknowledgement, with explicit prepared-proxy cleanup; retained local reconnect now uses fresh authorization/identities, cancellable 1–60 second backoff with ten-attempt limits and no call replay. The read-only wait_for_mcp tool now waits up to sixty seconds for named connecting servers through normal permission/hook/cancellation boundaries, with golden and actual delayed-startup coverage. Explicit registration scopes and a shared deferred-catalog materializer now validate the sole threshold, preserve client precedence and separate schema-free summaries; Session-persistent tool_search and runtime schema projection are now implemented locally, with unloaded refusal, durable replay/compaction and real MCP search/use coverage; plugin delivery and native acceptance remain open. Captured local roots now advertise the Location and configured writable paths with bounded roots/list callbacks during initialization, calls and idle processing; sampling remains open. Local server instructions now enter the scoped mcp/instructions Context Source, with registration-aware filtering, client shadowing and explicit replacement/withdrawal system messages; remote/native acceptance remains open. Required local servers now gate the first Turn before prompt promotion with named errors and retryable inbox inputs; cancellation preserves startup ownership, and replay/rewind preserve first-Turn completion. Graceful POSIX Location/runtime close now retains group identity through SIGTERM and five seconds before SIGKILL; interrupted close retains its deadline, while failed startup/cancelled calls keep immediate cleanup. Explicit call-timeout overrides now use approval-bound presence and global fallback; only valid advancing progress extends request inactivity. Per-server estimated output caps and side-effect-free cyber mcp get resolved review are implemented locally; dynamic header commands/refresh and remote/native acceptance remain open. Withheld definitions and remaining status transitions, CLI/TUI close, reconfiguration, remote reconnect and MCP transport and async scheduling remain incomplete; complete pre-event scheduling, other transports and native acceptance remain open. Exec trust integration, all remaining lifecycle events and client controls, JSON-RPC plugin host + `@cyber-code/plugin`, MCP OAuth and complete native acceptance remain open. Local stdio form elicitation now has call-owned typed questions and explicit consent with cancellation/revocation tests; 229 local Rust cases, workspace Clippy and SDK/spec checks pass. Remote/URL elicitation and native acceptance remain pending. Opt-in local sampling now uses small-model permission/budget gates and server-attributed usage, with actual native-provider timeout/close and budget tests; full platform and remote acceptance remain open. Synchronous local MCP-tool hooks now own dedicated connections through fresh trust, filtered/schema-checked calls, absolute deadlines, cancellation and durable receipt cleanup. Actual callback and CLI tests pass; remote hooks, async scheduling and full platform acceptance remain open. 4. **M1.4 Memory & intelligence:** **Status: in progress** (change `implement-m1-4-memory-intelligence`). **Acceptance (2026-10-18):** 28 of 28 P1 requirements done locally (28 a): every memory and code-intelligence row, demonstrated on the real binary, server and TUI in batch A, and since increment 14 browser verification (`cyber mcp browser install|health|cleanup`, a pinned browser MCP server run per Session with a fresh profile, trust, permission and network gating, hashed evidence with staleness, dev servers as `bash` `ready_url` Jobs, Session-end cleanup), demonstrated on macOS in batch C with a fake browser MCP server standing in for the pinned package. One real-browser run against the pinned package stays class (e) evidence. The memory, CLI-memory and intelligence suites run on native Windows in main CI, and read-only memory recovery is accepted natively (2026-10-23 [evidence pass](docs/implementation/p1-signoff-plan.md)); the `cyber-app` browser suite is Unix-only (`browser_cli` runs on Windows). The history below predates that sign-off and its "no M1.4 contract is fully accepted" is superseded. Shared memory document/write validation, deterministic indexes, bounded snapshots, typed settings, directory-bound read/scope ownership and Unix journaled mutation/explicit recovery and built-in memory tool admission and core/memory baseline/Safe Boundary updates and CLI review/edit/delete controls and fingerprint-bound local recovery with pending-database fences and authenticated HTTP/SDK CRUD and durable change receipts and shared-runtime reviewed reconciliation and authenticated HTTP/SDK controls and durable keyed receipt lookup for pinned journals and explicit CLI reconciliation through a running server and TUI note review/deletion/paired recovery and reviewed multiline editing with private Unix draft/request checkpoints, review-free restoration, exact request outcome reconciliation and confirmed retained-record forgetting are implemented locally. Windows private recoverable storage and legacy unknown-effect recovery, multi-client retention and complete client/native mutation acceptance, LSP diagnostics feedback, formatters and isolated browser verification with revision-linked artifacts remain required; no M1.4 contract is fully accepted. 5. **M1.5 Migration path:** `cyber import claude|codex|opencode`. **Acceptance (2026-10-18):** 6 of 10 P1 requirements done locally (6 a, 4 c). Increment 10 added reviewed writes, reports and idempotent annotations; increment 11 added the Claude, Codex and OpenCode source adapters, the read-time skill precedence and runtime `.mcp.json` loading; both were demonstrated on macOS in batch C, and the exit criterion below holds locally. Open: the keyring credential branch, OpenCode SQLite session counts, Claude `apiKeyHelper` and `.claude/rules`, and the TUI approval prompt for `.mcp.json` servers. ACP and the VS Code extension moved to P3 (change `defer-editor-integration`). diff --git a/crates/cyber-sandbox/examples/plugin_unix_socket_probe.rs b/crates/cyber-sandbox/examples/plugin_unix_socket_probe.rs index 8bf69eec0..4c701bfdb 100644 --- a/crates/cyber-sandbox/examples/plugin_unix_socket_probe.rs +++ b/crates/cyber-sandbox/examples/plugin_unix_socket_probe.rs @@ -41,10 +41,20 @@ mod probe { fn receipt(listener: &UnixListener, observed: &Value) -> bool { assert_eq!(observed["started"], true); + let connected = [ + "connected", + "relative_escape_connected", + "alias_escape_connected", + ] + .iter() + .any(|key| observed[*key] == true); + let sent = ["sent", "relative_escape_sent", "alias_escape_sent"] + .iter() + .any(|key| observed[*key] == true); match listener.accept() { Ok((mut stream, _)) => { - assert_eq!(observed["connected"], true); - assert_eq!(observed["sent"], true); + assert!(connected); + assert!(sent); stream .set_read_timeout(Some(Duration::from_secs(1))) .unwrap(); @@ -55,8 +65,8 @@ mod probe { } Err(error) => { assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); - assert_eq!(observed["connected"], false); - assert_eq!(observed["sent"], false); + assert!(!connected); + assert!(!sent); false } } @@ -72,6 +82,8 @@ mod probe { BrokerEscape, BrokerEscapeNoPid, BrokerSaturation, + BrokerPrivateStream, + BrokerPrivateStreamMutation, Unix9, } impl Mode { @@ -85,6 +97,8 @@ mod probe { | Self::BrokerEscape | Self::BrokerEscapeNoPid | Self::BrokerSaturation + | Self::BrokerPrivateStream + | Self::BrokerPrivateStreamMutation ) } fn broker(self) -> bool { @@ -96,6 +110,14 @@ mod probe { | Self::BrokerEscape | Self::BrokerEscapeNoPid | Self::BrokerSaturation + | Self::BrokerPrivateStream + | Self::BrokerPrivateStreamMutation + ) + } + fn private_stream(self) -> bool { + matches!( + self, + Self::BrokerPrivateStream | Self::BrokerPrivateStreamMutation ) } fn escape(self) -> Option { @@ -109,7 +131,7 @@ mod probe { fn observe(policy: &LinuxPolicy<'_>, mode: Mode) -> Value { let mut plan = policy.compose().unwrap(); - if mode == Mode::Unix9 { + if mode == Mode::Unix9 || mode.private_stream() { plan.landlock .push_str(&format!("U\t{}\n", policy.scratch.display())); } @@ -142,6 +164,10 @@ mod probe { ..policy }; let observed = observe(&pair_policy, mode); + let delivered = receipt(listener, &observed); + if mode.private_stream() { + check_private_stream(&observed, &pair_policy, label, delivered); + } assert_eq!( observed["project_read"], pair_policy.fs_read, "native pair read control: {observed}" @@ -153,24 +179,71 @@ mod probe { for key in ["connected", "pair_reconnect", "pair_bind"] { assert_eq!( observed[key], false, - "private pair escape: {key}: {observed}" + "private pair escape: {key}: {label} read={} write={}: {observed}", + pair_policy.fs_read, pair_policy.fs_write, ); } check_pair_support(&observed, mode); - let delivered = receipt(listener, &observed); assert!(!delivered, "pair reached host: {observed}"); json!({"socket": label, "fs_read": pair_policy.fs_read, "fs_write": pair_policy.fs_write, "shell": false, "mode": "pair", "host_received_fixture": delivered, "observed": observed}) } + fn check_private_stream( + observed: &Value, + policy: &LinuxPolicy<'_>, + label: &str, + delivered: bool, + ) { + assert!( + !delivered, + "private relative path escaped scratch: {}", + json!({"host_received_fixture":delivered,"observed":observed}) + ); + for key in [ + "relative_root_roundtrip", + "relative_parent_roundtrip", + "outside_cwd_roundtrip", + "alias_relative_roundtrip", + "alias_absolute_roundtrip", + "alias_directory_roundtrip", + ] { + assert_eq!( + observed[key], true, + "private relative stream exchange: {key}: {observed}" + ); + } + assert_eq!(observed["relative_escape_connected"], false); + assert_eq!(observed["relative_escape_sent"], false); + assert_eq!(observed["alias_escape_connected"], false); + assert_eq!(observed["alias_escape_sent"], false); + assert_eq!( + observed["regular_reparent"], policy.fs_write, + "private IPC layer changed ordinary file moves: {label} read={} write={}: {observed}", + policy.fs_read, policy.fs_write, + ); + if label == "public" && policy.fs_write { + assert_eq!( + observed["cross_mount_alias_errno"], + libc::EXDEV, + "a host socket alias crossed the private scratch mount: {observed}" + ); + } + } + fn check_pair_support(observed: &Value, mode: Mode) { - for key in ["datagram_pair", "seqpacket_pair", "scratch_unix_roundtrip"] { + for key in ["datagram_pair", "seqpacket_pair"] { assert_eq!( observed[key], mode == Mode::Unix9, "pair/local support: {key}: {observed}" ); } + assert_eq!( + observed["scratch_unix_roundtrip"], + mode == Mode::Unix9 || mode.private_stream(), + "private scratch stream roundtrip: {observed}" + ); assert_eq!( observed["tcp_roundtrip"], mode.ip_servers(), @@ -186,6 +259,55 @@ mod probe { } } + fn peer_identity_row(policy: &LinuxPolicy<'_>) -> Value { + let target_args = [ + policy.target_args[0].clone(), + policy.target_args[1].clone(), + "peer-identity".into(), + policy + .scratch + .join("identity.sock") + .to_str() + .unwrap() + .into(), + ]; + let control: Value = serde_json::from_slice( + &run(Command::new(policy.target).args(&target_args).env_clear()).stdout, + ) + .unwrap(); + assert_eq!( + control["exchanged"], true, + "unconfined peer exchange: {control}" + ); + assert_eq!(control["peer_checks"], 1); + assert_eq!( + control["peer_matches"], true, + "unconfined accepted peer: {control}" + ); + assert_eq!(control["client_peer_matches"], true); + let observed = observe( + &LinuxPolicy { + target_args: &target_args, + ..*policy + }, + Mode::BrokerPrivateStream, + ); + assert_eq!( + observed["exchanged"], true, + "broker peer exchange: {observed}" + ); + assert_eq!(observed["peer_checks"], 1); + assert_eq!( + observed["client_peer_matches"], true, + "broker listener identity: {observed}" + ); + // Record the accepted side honestly. A successful byte exchange cannot + // turn a peer-identity mismatch into private IPC compatibility. + json!({"mode":"peer-identity", "fs_read":policy.fs_read, "fs_write":policy.fs_write, + "shell":false,"compatibility_accepted":observed["peer_matches"] == true, + "unconfined_control":control,"observed":observed}) + } + fn candidate_rows( policy: LinuxPolicy<'_>, listener: &UnixListener, @@ -193,6 +315,14 @@ mod probe { mode: Mode, ) -> Vec { let mut rows = Vec::new(); + if mode == Mode::BrokerPrivateStream && label == "outside" { + rows.push(peer_identity_row(&policy)); + } + if mode == Mode::BrokerPrivateStreamMutation && label == "outside" { + let observed = super::cancellation::observe_unix_mutation(&policy, listener); + rows.push(json!({"mode":"unix-mutation","socket":label, + "fs_read":policy.fs_read,"fs_write":policy.fs_write,"shell":false,"observed":observed})); + } if mode == Mode::BrokerSaturation && label == "outside" { let observed = super::cancellation::observe_saturation(&policy); rows.push( @@ -245,6 +375,8 @@ mod probe { Some("--broker-escape") => Mode::BrokerEscape, Some("--broker-escape-no-pid") => Mode::BrokerEscapeNoPid, Some("--broker-saturation") => Mode::BrokerSaturation, + Some("--broker-private-stream") => Mode::BrokerPrivateStream, + Some("--broker-private-stream-mutation") => Mode::BrokerPrivateStreamMutation, Some("--landlock9") => Mode::Unix9, _ => panic!("unknown candidate mode"), }; @@ -342,7 +474,11 @@ mod probe { assert_eq!(observed["project_read"], read, "native read control"); let delivered = receipt(&listeners[index], &observed); if candidate || label == "private-mask" { - assert!(!delivered, "socket denial: {label}: {observed}"); + assert!( + !delivered, + "socket denial: {label}: {}", + json!({"host_received_fixture":delivered,"observed":observed}) + ); } rows.push(json!({"socket": label, "fs_read": read, "fs_write": write, "shell": false, "host_received_fixture": delivered, "observed": observed})); @@ -356,6 +492,8 @@ mod probe { "crates/cyber-sandbox/tests/fixtures/plugin_unix_socket.c", "crates/cyber-sandbox/tests/fixtures/plugin_socket_cancel.h", "crates/cyber-sandbox/tests/fixtures/plugin_socket_mutation.h", + "crates/cyber-sandbox/tests/fixtures/plugin_unix_mutation.h", + "crates/cyber-sandbox/tests/fixtures/plugin_unix_relative.h", "crates/cyber-sandbox/tests/fixtures/plugin_process_escape.h", "crates/cyber-sandbox/tests/fixtures/plugin_socket_saturation.h", "crates/cyber-sandbox/examples/support/plugin_socket_cancel.rs", @@ -373,6 +511,8 @@ mod probe { "scripts/probes/plugin-native-entry/linux_socket_candidate.h", "scripts/probes/plugin-native-entry/linux_socket_broker.h", "scripts/probes/plugin-native-entry/linux_socket_broker_mutation.h", + "scripts/probes/plugin-native-entry/linux_socket_private_stream.h", + "scripts/probes/plugin-native-entry/linux_socket_private_unix.h", "scripts/probes/plugin-native-entry/linux_socket_landlock9.h", "scripts/probes/plugin-native-entry/linux_unix9_policy.h", "scripts/probes/plugin-native-entry/build_socket_candidate.py", @@ -385,7 +525,7 @@ mod probe { } let report = json!({"platform": std::env::consts::OS, "architecture": std::env::consts::ARCH, "checkout": std::env::var("GITHUB_SHA").ok(), "run_id": std::env::var("GITHUB_RUN_ID").ok(), - "candidate": candidate, "landlock9": mode == Mode::Unix9, "socket_broker": mode.broker(), "cancellation_probe": mode == Mode::BrokerCancel, "mutation_probe": mode == Mode::BrokerMutation, "escape_probe": mode.escape(), "saturation_probe": mode == Mode::BrokerSaturation, "sources": sources, "source_sha256": format!("{:x}", hash.finalize()), + "candidate": candidate, "landlock9": mode == Mode::Unix9, "socket_broker": mode.broker(), "private_stream_probe": mode.private_stream(), "unix_mutation_probe": mode == Mode::BrokerPrivateStreamMutation, "cancellation_probe": mode == Mode::BrokerCancel, "mutation_probe": mode == Mode::BrokerMutation, "escape_probe": mode.escape(), "saturation_probe": mode == Mode::BrokerSaturation, "sources": sources, "source_sha256": format!("{:x}", hash.finalize()), "helper_sha256": format!("{:x}", Sha256::digest(std::fs::read(helper).unwrap())), "rows": rows, "scope": "Fixture-owned socket, caller and worker observations in experimental helpers only. Proof rows are case-specific; no ambient service contact or production capability acceptance."}); std::fs::write( diff --git a/crates/cyber-sandbox/examples/support/plugin_socket_cancel.rs b/crates/cyber-sandbox/examples/support/plugin_socket_cancel.rs index 969584d1e..9735dd9f7 100644 --- a/crates/cyber-sandbox/examples/support/plugin_socket_cancel.rs +++ b/crates/cyber-sandbox/examples/support/plugin_socket_cancel.rs @@ -3,6 +3,7 @@ use cyber_sandbox::plugin_policy::LinuxPolicy; use serde_json::{Value, json}; use std::collections::BTreeSet; use std::io::{BufRead, BufReader, Read, Write}; +use std::os::unix::net::UnixListener; use std::process::{Child, ChildStdin, Command, ExitStatus, Stdio}; use std::sync::mpsc::{self, Receiver, Sender}; use std::time::{Duration, Instant}; @@ -17,6 +18,7 @@ enum Message { enum Case { Cancellation, Mutation, + UnixMutation, Escape, Saturation, } @@ -25,6 +27,7 @@ impl Case { match self { Self::Cancellation => "cancel", Self::Mutation => "mutation", + Self::UnixMutation => "unix-mutation", Self::Escape => "escape", Self::Saturation => "saturation", } @@ -92,6 +95,7 @@ struct Trace { owner_exited_before_cleanup: Option, pressure: Option, pressure_cancelled: Option, + host_received: Option, } impl Trace { fn record(&mut self, message: Message) -> Result<(), String> { @@ -160,7 +164,7 @@ impl Trace { if matches!(case, Case::Escape) { return false; } - if matches!(case, Case::Mutation) { + if matches!(case, Case::Mutation | Case::UnixMutation) { let Some(tid) = self.pinned else { return false; }; @@ -202,12 +206,14 @@ impl Trace { json!({"ready":self.ready,"finished":self.finished,"cancelled_pid":self.cancelled, "pinned_tid":self.pinned,"started":self.started,"reaped":self.reaped,"audit":self.audit, "stdout_closed_before_cleanup":self.closed_before_cleanup,"owner_exited_before_cleanup":self.owner_exited_before_cleanup, - "pressure":self.pressure,"pressure_cancelled":self.pressure_cancelled,"cancelled_workers":self.cancelled_workers}) + "pressure":self.pressure,"pressure_cancelled":self.pressure_cancelled,"cancelled_workers":self.cancelled_workers, + "host_received_fixture":self.host_received}) } fn verify(&self, case: Case, released: bool, status: ExitStatus) { match case { Case::Cancellation => self.verify_cancel(released, status), Case::Mutation => self.verify_mutation(released, status), + Case::UnixMutation => self.verify_unix_mutation(released, status), Case::Escape => self.verify_escape(status), Case::Saturation => self.verify_pressure(released, status), } @@ -256,6 +262,33 @@ impl Trace { self.report() ); } + fn verify_unix_mutation(&self, released: bool, status: ExitStatus) { + assert!( + released, + "missing UNIX post-pin handshake: {}", + self.report() + ); + let finished = self + .finished + .as_ref() + .expect("missing UNIX mutation result"); + let correct = [ + "fd_replaced", + "address_changed", + "path_replaced", + "original_unlinked", + "result_ok", + "sent", + "private_roundtrip", + ] + .iter() + .all(|key| finished[key] == true); + assert!( + status.success() && correct && self.host_received == Some(false), + "private UNIX mutation escaped the pinned endpoint: {}", + self.report() + ); + } fn verify_escape(&self, status: ExitStatus) { assert_eq!( self.ready.as_ref().expect("missing descendant receipt")["controlled_stdin"], @@ -369,29 +402,51 @@ fn drive( } pub fn observe(policy: &LinuxPolicy<'_>) -> Value { - observe_case(policy, Case::Cancellation, true) + observe_case(policy, Case::Cancellation, true, None) } pub fn observe_mutation(policy: &LinuxPolicy<'_>) -> Value { - observe_case(policy, Case::Mutation, true) + observe_case(policy, Case::Mutation, true, None) +} +pub fn observe_unix_mutation(policy: &LinuxPolicy<'_>, listener: &UnixListener) -> Value { + observe_case(policy, Case::UnixMutation, true, Some(listener)) } pub fn observe_escape(policy: &LinuxPolicy<'_>, private_pid: bool) -> Value { - observe_case(policy, Case::Escape, private_pid) + observe_case(policy, Case::Escape, private_pid, None) } pub fn observe_saturation(policy: &LinuxPolicy<'_>) -> Value { - observe_case(policy, Case::Saturation, true) + observe_case(policy, Case::Saturation, true, None) } -fn observe_case(policy: &LinuxPolicy<'_>, case: Case, private_pid: bool) -> Value { - let args = [ +fn observe_case( + policy: &LinuxPolicy<'_>, + case: Case, + private_pid: bool, + listener: Option<&UnixListener>, +) -> Value { + let mut args = vec![ policy.target_args[0].clone(), policy.target_args[1].clone(), case.argument().into(), ]; + if matches!(case, Case::UnixMutation) { + args.push( + policy + .scratch + .join("mutation.sock") + .to_str() + .unwrap() + .into(), + ); + } let policy = LinuxPolicy { target_args: &args, shell: matches!(case, Case::Escape) || policy.shell, ..*policy }; let mut plan = policy.compose().unwrap(); + if matches!(case, Case::UnixMutation) { + plan.landlock + .push_str(&format!("U\t{}\n", policy.scratch.display())); + } if !private_pid { assert_eq!( plan.arguments @@ -462,6 +517,27 @@ fn observe_case(policy: &LinuxPolicy<'_>, case: Case, private_pid: bool) -> Valu "fixture protocol failed: {outcome:?} {errors:?}: {}", trace.report() ); + if let Some(listener) = listener { + trace.host_received = Some(host_receipt(listener)); + } trace.verify(case, handshakes == case.handshakes(), status); trace.report() } + +fn host_receipt(listener: &UnixListener) -> bool { + match listener.accept() { + Ok((mut stream, _)) => { + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + let mut token = [0; 12]; + stream.read_exact(&mut token).unwrap(); + assert_eq!(&token, b"fixture-only"); + true + } + Err(error) => { + assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + false + } + } +} diff --git a/crates/cyber-sandbox/tests/fixtures/plugin_unix_mutation.h b/crates/cyber-sandbox/tests/fixtures/plugin_unix_mutation.h new file mode 100644 index 000000000..655c928cb --- /dev/null +++ b/crates/cyber-sandbox/tests/fixtures/plugin_unix_mutation.h @@ -0,0 +1,71 @@ +/* Real post-pin pathname mutation; all endpoints are owned fixture sockets. */ +#ifdef __linux__ +#include +struct unix_mutation_call { int fd, result; struct sockaddr_un address; }; +static void *unix_mutation_connect(void *argument) { + struct unix_mutation_call *call = argument; + printf("{\"phase\":\"mutation-ready\",\"request_tid\":%ld}\n", syscall(SYS_gettid)); + fflush(stdout); + /* Full sockaddr_un size is the audit fixture's unique pause selector. */ + call->result = connect(call->fd, (struct sockaddr *)&call->address, sizeof(call->address)); + return NULL; +} +static int unix_mutation_probe(const char *host, const char *path) { + struct sockaddr_un address = { .sun_family = AF_UNIX }; + if (strlen(host) >= sizeof(address.sun_path) || strlen(path) >= sizeof(address.sun_path)) return 3; + int parent = syscall(SYS_pidfd_open, getppid(), 0); + int server = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0); + int client = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (parent < 0 || server < 0 || client < 0) return 3; + strcpy(address.sun_path, path); + if (bind(server, (struct sockaddr *)&address, sizeof(address)) != 0 || listen(server, 1) != 0) return 3; + int identity = open(path, O_PATH | O_NOFOLLOW | O_CLOEXEC); + struct stat before, after, replacement; + if (identity < 0 || fstat(identity, &before) != 0 || !S_ISSOCK(before.st_mode)) return 3; + int fd = fcntl(client, F_DUPFD_CLOEXEC, 128); + int kept = fcntl(client, F_DUPFD_CLOEXEC, 129); + close(client); + if (fd < 0 || kept < 0) return 3; + struct unix_mutation_call call = { .fd = fd, .result = -1, .address = address }; + pthread_t thread; + if (pthread_create(&thread, NULL, unix_mutation_connect, &call) != 0) return 3; + if (getchar() != 'R') _exit(3); + int different = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (different < 0 || dup2(different, fd) != fd) _exit(3); + close(different); + memset(&call.address, 0, sizeof(call.address)); + call.address.sun_family = AF_UNIX; + strcpy(call.address.sun_path, host); + if (unlink(path) != 0 || symlink(host, path) != 0) _exit(3); + int path_replaced = lstat(path, &replacement) == 0 && S_ISLNK(replacement.st_mode) && + (before.st_dev != replacement.st_dev || before.st_ino != replacement.st_ino); + int original_unlinked = fstat(identity, &after) == 0 && before.st_dev == after.st_dev && + before.st_ino == after.st_ino && after.st_nlink == 0; + atomic_thread_fence(memory_order_seq_cst); + if (syscall(SYS_pidfd_send_signal, parent, SIGUSR2, NULL, 0) != 0 || + pthread_join(thread, NULL) != 0) _exit(3); + int domain = 0; + socklen_t length = sizeof(domain); + int fd_replaced = getsockopt(fd, SOL_SOCKET, SO_DOMAIN, &domain, &length) == 0 && domain == AF_INET; + int sent = call.result == 0 && send(kept, "fixture-only", 12, MSG_NOSIGNAL) == 12; + int peer = accept4(server, NULL, NULL, SOCK_CLOEXEC | SOCK_NONBLOCK); + char token[12], ack = 0; + int roundtrip = sent && peer >= 0 && recv(peer, token, sizeof(token), MSG_DONTWAIT) == 12 && + memcmp(token, "fixture-only", 12) == 0 && send(peer, "A", 1, MSG_NOSIGNAL) == 1 && + recv(kept, &ack, 1, MSG_DONTWAIT) == 1 && ack == 'A'; + printf("{\"phase\":\"mutation-finished\",\"fd_replaced\":%s,\"address_changed\":%s," + "\"path_replaced\":%s,\"original_unlinked\":%s,\"result_ok\":%s,\"sent\":%s,\"private_roundtrip\":%s}\n", + fd_replaced ? "true" : "false", strcmp(call.address.sun_path, host) == 0 ? "true" : "false", + path_replaced ? "true" : "false", original_unlinked ? "true" : "false", + call.result == 0 ? "true" : "false", sent ? "true" : "false", roundtrip ? "true" : "false"); + if (peer >= 0) close(peer); + close(parent); close(identity); close(fd); close(kept); close(server); unlink(path); + /* The owner verifies endpoint selection, including the deliberate host + delivery counterexample, after all fixture processes/pipes settle. */ + return 0; +} +#else +static int unix_mutation_probe(const char *host, const char *path) { + (void)host; (void)path; return 3; +} +#endif diff --git a/crates/cyber-sandbox/tests/fixtures/plugin_unix_relative.h b/crates/cyber-sandbox/tests/fixtures/plugin_unix_relative.h new file mode 100644 index 000000000..c90fbba6c --- /dev/null +++ b/crates/cyber-sandbox/tests/fixtures/plugin_unix_relative.h @@ -0,0 +1,87 @@ +/* Relative names must resolve from the guest's CWD, never the broker's. */ +struct relative_result { + int root, parent, outside_cwd, alias_relative, alias_absolute, alias_directory; + int escape_connected, escape_sent, alias_escape_connected, alias_escape_sent; +}; +#ifdef __linux__ +static int relative_connect_token(const char *path, int *sent) { + struct sockaddr_un address = { .sun_family = AF_UNIX }; + if (strlen(path) >= sizeof(address.sun_path)) return 0; + strcpy(address.sun_path, path); + int socket_fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + int connected = socket_fd >= 0 && connect(socket_fd, (struct sockaddr *)&address, + (socklen_t)(offsetof(struct sockaddr_un, sun_path) + strlen(path) + 1)) == 0; + *sent = connected && send(socket_fd, "fixture-only", 12, MSG_NOSIGNAL) == 12; + if (socket_fd >= 0) close(socket_fd); + return connected; +} + +static int relative_alias_exchange(const char *scratch, const char *name, + const char *value, const char *tail) { + char target[544], alias[544], destination[576]; + snprintf(target, sizeof(target), "%s/alias-target.sock", scratch); + snprintf(alias, sizeof(alias), "%s/%s", scratch, name); + snprintf(destination, sizeof(destination), "%s%s", alias, tail); + if (symlink(value == NULL ? target : value, alias) != 0) return 0; + int result = local_exchange_to(AF_UNIX, target, -1, destination); + unlink(alias); + return result; +} + +static int relative_outside_exchange(const char *scratch, int root) { + const char *name = strrchr(scratch, '/'); + if (name == NULL) return 0; + char target[544], destination[544]; + snprintf(target, sizeof(target), "%s/outside-cwd.sock", scratch); + snprintf(destination, sizeof(destination), "%s/outside-cwd.sock", name + 1); + int parent = openat(root, "..", O_PATH | O_DIRECTORY | O_CLOEXEC); + if (parent < 0) return 0; + int result = fchdir(parent) == 0 && local_exchange_to(AF_UNIX, target, -1, destination); + if (fchdir(root) != 0) _exit(3); + close(parent); + return result; +} + +static void relative_aliases(const char *scratch, int root, struct relative_result *result) { + result->alias_relative = relative_alias_exchange(scratch, "relative-link.sock", "alias-target.sock", ""); + result->alias_absolute = relative_alias_exchange(scratch, "absolute-link.sock", NULL, ""); + result->alias_directory = relative_alias_exchange(scratch, "directory-link", ".", "/alias-target.sock"); + result->outside_cwd = relative_outside_exchange(scratch, root); + char alias[544]; + snprintf(alias, sizeof(alias), "%s/escape-link.sock", scratch); + if (symlink("../host.sock", alias) == 0) { + result->alias_escape_connected = relative_connect_token(alias, &result->alias_escape_sent); + unlink(alias); + } +} + +static struct relative_result relative_probe(const char *scratch_socket) { + struct relative_result result = {0}; + if (scratch_socket == NULL || strlen(scratch_socket) >= 512) return result; + char scratch[512]; + strcpy(scratch, scratch_socket); + char *last = strrchr(scratch, '/'); + if (last == NULL) return result; + *last = '\0'; + int previous = open(".", O_PATH | O_DIRECTORY | O_CLOEXEC); + int root = open(scratch, O_PATH | O_DIRECTORY | O_CLOEXEC); + if (previous < 0 || root < 0 || fchdir(root) != 0) goto done; + result.root = local_exchange(AF_UNIX, "relative.sock", -1); + if (mkdir("relative-dir", 0700) != 0 || chdir("relative-dir") != 0) goto done; + result.parent = local_exchange(AF_UNIX, "../parent.sock", -1); + if (fchdir(root) != 0) _exit(3); + result.escape_connected = relative_connect_token("../host.sock", &result.escape_sent); + relative_aliases(scratch, root, &result); +done: + if (root >= 0) { unlinkat(root, "relative-dir", AT_REMOVEDIR); close(root); } + if (previous >= 0) { + if (fchdir(previous) != 0) _exit(3); + close(previous); + } + return result; +} +#else +static struct relative_result relative_probe(const char *scratch_socket) { + (void)scratch_socket; return (struct relative_result){0}; +} +#endif diff --git a/crates/cyber-sandbox/tests/fixtures/plugin_unix_socket.c b/crates/cyber-sandbox/tests/fixtures/plugin_unix_socket.c index e646ba2f3..f589beb4d 100644 --- a/crates/cyber-sandbox/tests/fixtures/plugin_unix_socket.c +++ b/crates/cyber-sandbox/tests/fixtures/plugin_unix_socket.c @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -25,7 +26,29 @@ static int abi(void) { #endif } -static int local_exchange(int family, const char *path, int server) { +#ifdef __linux__ +static int peer_checks, peer_matches = 1, client_peer_matches = 1; +static int peer_pid = -1, client_peer_pid = -1; +static void record_peer_identity(int client, int peer) { + struct ucred accepted = {0}, listener = {0}; + socklen_t accepted_size = sizeof(accepted), listener_size = sizeof(listener); + int accepted_ok = getsockopt(peer, SOL_SOCKET, SO_PEERCRED, &accepted, &accepted_size) == 0 && + accepted_size == sizeof(accepted); + int listener_ok = getsockopt(client, SOL_SOCKET, SO_PEERCRED, &listener, &listener_size) == 0 && + listener_size == sizeof(listener); + peer_checks++; + peer_pid = accepted_ok ? accepted.pid : -1; + client_peer_pid = listener_ok ? listener.pid : -1; + peer_matches = peer_matches && accepted_ok && accepted.pid == getpid() && + accepted.uid == getuid() && accepted.gid == getgid(); + client_peer_matches = client_peer_matches && listener_ok && listener.pid == getpid() && + listener.uid == getuid() && listener.gid == getgid(); +} +#else +static void record_peer_identity(int client, int peer) { (void)client; (void)peer; } +#endif + +static int local_exchange_to(int family, const char *path, int server, const char *destination) { int client = -1, peer = -1, ok = 0; struct sockaddr_storage storage = {0}; socklen_t length; @@ -44,12 +67,20 @@ static int local_exchange(int family, const char *path, int server) { if (server < 0) server = socket(family, SOCK_STREAM, 0); if (server < 0 || bind(server, (struct sockaddr *)&storage, length) != 0 || listen(server, 1) != 0 || getsockname(server, (struct sockaddr *)&storage, &length) != 0) goto done; + if (destination != NULL) { + struct sockaddr_un *address = (struct sockaddr_un *)&storage; + if (family != AF_UNIX || strlen(destination) >= sizeof(address->sun_path)) goto done; + strcpy(address->sun_path, destination); + length = (socklen_t)(offsetof(struct sockaddr_un, sun_path) + strlen(destination) + 1); + } client = socket(family, SOCK_STREAM, 0); if (client < 0 || connect(client, (struct sockaddr *)&storage, length) != 0) goto done; peer = accept(server, NULL, NULL); - char token[12]; + char token[12], ack = 0; ok = peer >= 0 && send(client, "fixture-only", 12, MSG_NOSIGNAL) == 12 && - recv(peer, token, sizeof(token), MSG_WAITALL) == 12 && memcmp(token, "fixture-only", 12) == 0; + recv(peer, token, sizeof(token), MSG_WAITALL) == 12 && memcmp(token, "fixture-only", 12) == 0 && + send(peer, "A", 1, MSG_NOSIGNAL) == 1 && recv(client, &ack, 1, MSG_WAITALL) == 1 && ack == 'A'; + if (ok && family == AF_UNIX) record_peer_identity(client, peer); done: if (peer >= 0) close(peer); if (client >= 0) close(client); @@ -58,6 +89,12 @@ static int local_exchange(int family, const char *path, int server) { return ok; } +static int local_exchange(int family, const char *path, int server) { + return local_exchange_to(family, path, server, NULL); +} + +#include "plugin_unix_relative.h" + #ifdef __linux__ struct thread_exchange { int descriptor; int roundtrip; }; static void *thread_exchange(void *argument) { @@ -95,6 +132,28 @@ static int thread_local_exchange(void) { static int thread_local_exchange(void) { return 0; } #endif +static int regular_reparent(const char *data_path) { + char first[512], second[512], source[544], target[544]; + int a = snprintf(first, sizeof(first), "%s-move-a", data_path); + int b = snprintf(second, sizeof(second), "%s-move-b", data_path); + if (a <= 0 || b <= 0 || (size_t)a >= sizeof(first) || (size_t)b >= sizeof(second)) return 0; + snprintf(source, sizeof(source), "%s/entry", first); + snprintf(target, sizeof(target), "%s/entry", second); + int ok = 0, fd = -1; + struct stat before, after, old; + if (mkdir(first, 0700) != 0 || mkdir(second, 0700) != 0) goto done; + fd = open(source, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600); + if (fd < 0 || write(fd, "T", 1) != 1 || fstat(fd, &before) != 0) goto done; + close(fd); fd = -1; + ok = rename(source, target) == 0 && lstat(target, &after) == 0 && + S_ISREG(after.st_mode) && before.st_dev == after.st_dev && before.st_ino == after.st_ino && + lstat(source, &old) < 0 && errno == ENOENT; +done: + if (fd >= 0) close(fd); + unlink(source); unlink(target); rmdir(first); rmdir(second); + return ok; +} + static int pair_probe(const struct sockaddr_un *address, socklen_t length, const char *data_path, const char *scratch_socket) { int pair[2]; @@ -134,24 +193,45 @@ static int pair_probe(const struct sockaddr_un *address, socklen_t length, int tcp = scratch_socket != NULL && local_exchange(AF_INET, NULL, -1); int thread_tcp = scratch_socket != NULL && thread_local_exchange(); int scratch_ipc = scratch_socket != NULL && local_exchange(AF_UNIX, scratch_socket, -1); + struct relative_result relative = relative_probe(scratch_socket); + int moved = regular_reparent(data_path); + int alias_error = 0; + if (scratch_socket != NULL) { + char alias[512]; + int size = snprintf(alias, sizeof(alias), "%s-alias", scratch_socket); + if (size <= 0 || (size_t)size >= sizeof(alias)) return 2; + if (link(address->sun_path, alias) == 0) unlink(alias); + else alias_error = errno; + } printf("{\"started\":true,\"connected\":%s,\"sent\":%s,\"project_read\":%s," "\"pair_roundtrip\":%s,\"pair_disconnect\":%s,\"pair_reconnect\":%s," "\"pair_bind\":%s,\"pair_listen\":%s,\"pair_sendto\":%s,\"pair_sendmsg\":%s," "\"datagram_pair\":%s,\"seqpacket_pair\":%s,\"landlock_abi\":%d," - "\"tcp_roundtrip\":%s,\"thread_tcp_roundtrip\":%s,\"scratch_unix_roundtrip\":%s}\n", + "\"tcp_roundtrip\":%s,\"thread_tcp_roundtrip\":%s,\"scratch_unix_roundtrip\":%s,\"regular_reparent\":%s," + "\"cross_mount_alias_errno\":%d,\"relative_root_roundtrip\":%s,\"relative_parent_roundtrip\":%s," + "\"relative_escape_connected\":%s,\"relative_escape_sent\":%s," + "\"outside_cwd_roundtrip\":%s,\"alias_relative_roundtrip\":%s,\"alias_absolute_roundtrip\":%s," + "\"alias_directory_roundtrip\":%s,\"alias_escape_connected\":%s,\"alias_escape_sent\":%s}\n", (connected || reconnected) ? "true" : "false", sent ? "true" : "false", readable ? "true" : "false", roundtrip ? "true" : "false", disconnected ? "true" : "false", reconnected ? "true" : "false", bound ? "true" : "false", listening ? "true" : "false", sent_to ? "true" : "false", sent_message ? "true" : "false", datagram ? "true" : "false", sequence ? "true" : "false", abi(), - tcp ? "true" : "false", thread_tcp ? "true" : "false", scratch_ipc ? "true" : "false"); + tcp ? "true" : "false", thread_tcp ? "true" : "false", scratch_ipc ? "true" : "false", + moved ? "true" : "false", alias_error, + relative.root ? "true" : "false", relative.parent ? "true" : "false", + relative.escape_connected ? "true" : "false", relative.escape_sent ? "true" : "false", + relative.outside_cwd ? "true" : "false", relative.alias_relative ? "true" : "false", + relative.alias_absolute ? "true" : "false", relative.alias_directory ? "true" : "false", + relative.alias_escape_connected ? "true" : "false", relative.alias_escape_sent ? "true" : "false"); return 0; } #include "plugin_socket_cancel.h" #include "plugin_socket_saturation.h" #include "plugin_socket_mutation.h" +#include "plugin_unix_mutation.h" #include "plugin_process_escape.h" int main(int argc, char **argv) { @@ -164,6 +244,18 @@ int main(int argc, char **argv) { if (argc == 4 && strcmp(argv[3], "mutation") == 0) return mutation_probe(); if (argc == 4 && strcmp(argv[3], "escape") == 0) return escape_probe(); if (argc == 4 && strcmp(argv[3], "saturation") == 0) return saturation_probe(); +#ifdef __linux__ + if (argc == 5 && strcmp(argv[3], "peer-identity") == 0) { + int exchanged = local_exchange(AF_UNIX, argv[4], -1); + printf("{\"started\":true,\"exchanged\":%s,\"peer_checks\":%d," + "\"expected_pid\":%d,\"accepted_pid\":%d,\"client_peer_pid\":%d," + "\"peer_matches\":%s,\"client_peer_matches\":%s}\n", + exchanged ? "true" : "false", peer_checks, (int)getpid(), peer_pid, client_peer_pid, + peer_matches ? "true" : "false", client_peer_matches ? "true" : "false"); + return 0; + } +#endif + if (argc == 5 && strcmp(argv[3], "unix-mutation") == 0) return unix_mutation_probe(argv[1], argv[4]); if (argc >= 4) { if (strcmp(argv[3], "pair") != 0) return 2; return pair_probe(&address, diff --git a/crates/cyber-tools/src/file_changes/owner.rs b/crates/cyber-tools/src/file_changes/owner.rs index 4c0ce6716..b80ba91cd 100644 --- a/crates/cyber-tools/src/file_changes/owner.rs +++ b/crates/cyber-tools/src/file_changes/owner.rs @@ -28,6 +28,9 @@ const RECONCILE: Duration = Duration::from_secs(3); #[derive(Default)] struct Membership { sessions: BTreeMap, + /// Readmission reuses this owner while the previous incarnation settles. + /// Cancelling an empty pending owner would grow an unbounded join chain. + waiting_for_previous: bool, /// Keep only the newest committed acknowledgement per current Session. /// Both maps share the 256-Session bound; input bursts cannot grow a queue. renewals: BTreeMap, @@ -43,8 +46,8 @@ impl Membership { self.renewals.remove(session); self.scopes.remove(session); } - fn is_empty(&self) -> bool { - self.sessions.is_empty() + fn can_stop(&self) -> bool { + self.sessions.is_empty() && !self.waiting_for_previous } } @@ -69,15 +72,20 @@ impl Registry { } let mut members = entry.members.lock().unwrap_or_else(PoisonError::into_inner); members.remove(session); - if members.is_empty() { + if members.can_stop() { entry.cancel.cancel(); } } } } impl Entry { - fn add(&self, host: &BuiltinHost, session: &str) { + fn add_if_live(&self, host: &BuiltinHost, session: &str) -> bool { let mut members = self.members.lock().unwrap_or_else(PoisonError::into_inner); + // Activation may cancel an empty pending owner. Serialize admission with + // that decision rather than adding an already-cancelled child token. + if self.cancel.is_cancelled() { + return false; + } if members.sessions.contains_key(session) || members.sessions.len() < MAX_SESSIONS { members .sessions @@ -91,6 +99,7 @@ impl Entry { "FileChanged coverage unavailable: Session limit reached", ); } + true } } @@ -115,9 +124,8 @@ impl Observers { .retain(|_, entry| !entry.task.is_finished()); registry.remove_other_memberships(&path, &info.id); if let Some(entry) = registry.entries.get(&path) - && !entry.cancel.is_cancelled() + && entry.add_if_live(host, &info.id) { - entry.add(host, &info.id); return; } let previous = registry.entries.remove(&path); @@ -132,6 +140,7 @@ impl Observers { let cancel = CancellationToken::new(); let members = Arc::new(Mutex::new(Membership { sessions: BTreeMap::from([(info.id.clone(), cancel.child_token())]), + waiting_for_previous: previous.is_some(), renewals: BTreeMap::new(), scopes: BTreeSet::new(), explicit: Hints::default(), @@ -145,6 +154,13 @@ impl Observers { if let Some(previous) = previous { let _ = previous.task.await; } + { + let mut members = membership.lock().unwrap_or_else(PoisonError::into_inner); + members.waiting_for_previous = false; + if members.sessions.is_empty() { + owner.cancel(); + } + } run(weak, root, directory, membership, owner).await; }); registry.entries.insert( @@ -210,7 +226,7 @@ impl Observers { for entry in registry.entries.values() { let mut members = entry.members.lock().unwrap_or_else(PoisonError::into_inner); members.remove(session); - if members.is_empty() { + if members.can_stop() { entry.cancel.cancel(); } } @@ -859,6 +875,96 @@ async fn start_observation( mod tests { use super::*; + #[tokio::test] + async fn readmission_reuses_one_pending_replacement_until_previous_owner_settles() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().canonicalize().unwrap(); + let store = Arc::new( + cyber_store::Store::open(cyber_store::StoreOptions::new( + cyber_core::paths::DatabaseLocation::Memory, + cyber_server::runtime::Runtime::registry(), + )) + .unwrap(), + ); + let host = BuiltinHost::new(crate::HostOptions { + store, + tool_output_dir: root.join("tool-output"), + allowed_dirs: Vec::new(), + home: root.join("home"), + shell: "bash".into(), + config: Arc::new(|_| Ok((serde_json::json!({}), BTreeMap::new()))), + global_config_dir: root.join("config"), + env: Arc::new(std::collections::HashMap::::new()), + models: None, + temp_dir: root.join("tmp"), + sandbox_policy: None, + sandbox_helper: None, + isolation: crate::IsolationOptions { + dangerously_bypass: false, + container: false, + }, + }); + let info: SessionInfo = serde_json::from_value(serde_json::json!({ + "id": "session", "title": "fixture", "directory": root, + "parent_id": null, "agent": "default", "model": "fixture/model", + "mode": "default", "created_ms": 0 + })) + .unwrap(); + let gate = Arc::new(tokio::sync::Notify::new()); + let held = Arc::clone(&gate); + let previous = tokio::spawn(async move { held.notified().await }); + let cancel = CancellationToken::new(); + cancel.cancel(); + let observers = Observers(Mutex::new(Registry { + entries: BTreeMap::from([( + root.clone(), + Entry { + members: Arc::new(Mutex::new(Membership::default())), + cancel, + task: previous, + }, + )]), + closed: false, + })); + let mut replacements = std::collections::HashSet::new(); + let mut removed_tokens_cancelled = true; + for _ in 0..256 { + observers.refresh(&host, &info); + let token = { + let registry = observers.0.lock().unwrap(); + let entry = ®istry.entries[&root]; + replacements.insert(entry.task.id()); + member_cancellation(&entry.members, &info.id).unwrap() + }; + observers.remove(&info.id); + removed_tokens_cancelled &= token.is_cancelled(); + } + observers.refresh(&host, &info); + let final_token = { + let registry = observers.0.lock().unwrap(); + let entry = ®istry.entries[&root]; + replacements.insert(entry.task.id()); + member_cancellation(&entry.members, &info.id).unwrap() + }; + let final_membership_live = !final_token.is_cancelled(); + gate.notify_one(); + observers.shutdown().await; + assert!(removed_tokens_cancelled); + assert!( + final_membership_live, + "readmission needs live callback authority" + ); + assert!( + final_token.is_cancelled(), + "shutdown must revoke pending authority" + ); + assert_eq!( + replacements.len(), + 1, + "readmission must not grow a task chain behind a settling owner" + ); + } + #[test] fn removing_membership_cancels_held_delivery_without_stopping_other_members() { let owner = CancellationToken::new(); diff --git a/docs/features.md b/docs/features.md index b1cc548bf..8184c6bc1 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,6 +1,6 @@ # Feature reference -Implemented behavior and remaining scope, reviewed on **2026-10-16** (Cross-session messaging, Direct peers and Feature flags on 2026-10-19; Agent teams, Models and Sessions on 2026-10-20; Goals, Loops and budget wording reconciled on 2026-10-22; experimental Project collaboration on 2026-10-24). Start with the [README](../README.md); use the [P1 status](implementation/p1-status.md) and [acceptance audit](implementation/p1-acceptance-audit.md) for detailed evidence. +Implemented behavior and remaining scope, reviewed on **2026-10-16** (Cross-session messaging, Direct peers and Feature flags on 2026-10-19; Agent teams, Models and Sessions on 2026-10-20; Goals, Loops and budget wording reconciled on 2026-10-22; experimental Project collaboration on 2026-10-24; hook producers on 2026-10-30). Start with the [README](../README.md); use the [P1 status](implementation/p1-status.md) and [acceptance audit](implementation/p1-acceptance-audit.md) for detailed evidence. “Available” describes implemented local-core behavior. “Partial P1” means working pieces exist and the full contract still needs implementation or acceptance. @@ -33,7 +33,7 @@ Implemented behavior and remaining scope, reviewed on **2026-10-16** (Cross-sess | Cancellation | Durable admission closure, exclusive child result ownership, bounded stop reports and matched reopening | Partial P1; unknown-effect recovery, cross-process actor cancellation and exec/TUI adoption remain | | Spending | Durable own/descendant billing, the usage step ledger, the atomic usage API with per-model, duration, code-change and Budget breakdown, the TUI Usage tab (`/usage`, `/cost`) with Budget meters, usage statistics (`GET /api/v1/usage/stats`: local-day activity, streaks and per-model totals) shown by the TUI Stats tab (`/stats`, heatmap and Models) and `cyber stats`, usage insights (`GET /api/v1/usage/insights`: subagent-heavy, long-running and large-context shares plus subagent groups) shown in the Usage tab, provider-reported plan and rate-limit windows (`GET /api/v1/usage/limits`, read from provider response headers, never estimated) shown as Usage tab meters, Session recaps (`GET|POST /api/v1/sessions/:id/recap`, `small` model, cached by `through_seq`) shown at the top of the TUI transcript, and soft Session budgets | Partial P1; reservations, daily cap enforcement and complete enforcement remain | | Plugins | Declarative plugins (`cyber-plugin.json`): skills, commands, agents, hooks, `.mcp.json` servers and TUI themes, namespaced `:` on a collision; user, project (with `.cyber/plugins.lock`) and local scopes plus managed `policy.plugins.disabled`; path, git (pinned commit) and npm (`--ignore-scripts`) sources cached at `/plugins//-/` (content-addressed, shared by every scope and checkout); trust bound to name, version and content digest with a review prompt for project plugins; `cyber plugins install/list/info/update/remove/enable/disable/validate`, `GET /api/v1/plugins`, TUI `/plugins` and `/reload-plugins`; `--pure`/`CYBER_PURE=1`; plugin host processes for `main` (JSON-RPC 2.0 over stdio, always sandboxed with the declared `network` and `fs.write` capabilities, `network` never wider than the resolved sandbox network and its denylist; plugin `http`, `prompt` and `mcp_tool` hooks load only within the declared capabilities, and plugin command hooks run with the plugin's network, contributed tools with crash isolation, restart backoff and crash-loop disable, validated `userConfig` with keyring secrets, `cyber plugins configure`) and the `@cyber-code/plugin` TypeScript kit | Partial P1; host-protocol hooks, providers, catalog transforms and `session/prompt`, `fs.read`/`shell` enforcement, plugin hosts on Windows, mods, marketplaces and signature verification remain | -| Hooks | Command (Unix shell, Windows PowerShell), HTTP, prompt and MCP-tool handlers; managed, global, project, local and invocation scopes with managed-only policy; exact handler trust with `--trust-project-hooks` for one exec run; tool and lifecycle events, context, in-process SDK handlers (`client.hooks.on`, scope `client`, run after every configured handler), `continue: false`, async handlers and durable receipts; CLI/TUI/API review and `cyber hooks test` | Partial P1; plugin hooks run last in scope `plugin`; sandboxed hook scopes on Windows and the events without a producer (`DirectoryAdded`, `FileChanged`, `CwdChanged`) remain | +| Hooks | Command (Unix shell, Windows PowerShell), HTTP, prompt and MCP-tool handlers; managed, global, project, local and invocation scopes with managed-only policy; exact handler trust with `--trust-project-hooks` for one exec run; tool and lifecycle events, context, in-process SDK handlers (`client.hooks.on`, scope `client`, run after every configured handler), `continue: false`, async handlers and durable receipts; CLI/TUI/API review and `cyber hooks test`; committed worktree Location changes produce `CwdChanged`, and bounded metadata observation produces `FileChanged` for tool, formatter, shell/background and external edits ([guide](local-core-guide.md#hook-configuration-and-trust-review)) | Partial P1; plugin hooks run last in scope `plugin`; sandboxed hook scopes on Windows, `DirectoryAdded`, explicit `Setup` initialization/marker semantics and combined producer acceptance remain. FileChanged resource/lifecycle acceptance is still open | | MCP | Local approvals, shared tools/status, startup waiting, schema search and bounded reconnect; remote servers over Streamable HTTP with legacy SSE fallback, `needs_auth` reporting, header commands and policy-checked networking; OAuth sign-in with `cyber mcp auth`/`logout`, token refresh, scope step-up and reconnect, with credentials in the macOS Keychain or Windows Credential Manager (owner-only file on Linux); resource tools and `@server:uri` mentions; server prompts as `/:` commands; form elicitation for local and remote servers (declined when unattended, URL mode refused, never opens links); `roots/list` with the Location and writable roots, reconnecting when they change; tools/prompts/resources `list_changed` refresh with `mcp.tools.changed.1`; `notifications/cancelled` on interrupt or timeout; sampling by `model_roles.small` for local and remote servers when `mcp.sampling.enabled`; organization `policy.mcp` allow/deny patterns (name, command, URL) and managed servers for every Location (blocked servers report `blocked by organization policy` and are never contacted); annotations drive modes (destructive tools always classified in `auto`, open-world tools ask `network` when the sandbox network is off) and reach hook envelopes as `tool_annotations` | Partial P1; prompt argument completion, blobs beyond 1 MiB, remote token revocation and native acceptance (including a real Keychain and Credential Manager round trip) remain | | MCP serve | `cyber mcp serve` exposes Sessions to other agents as trusted local clients: a stdio bridge to the shared server or loopback Streamable HTTP at `/api/v1/mcp/serve` (Unix socket or local password; loopback peers and `Host` only). Tools create, list, read and page Sessions, send literal prompts through the user path (`UserPromptSubmit` hooks run) with a wait or a receipt, re-attach with `cyber_prompt_wait` (also after a restart) and interrupt, and list, send and read cross-session messages (as the calling Session when known, else as the MCP client `mcp: