diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 301f2cb1..7e36704b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,16 +45,91 @@ jobs: fetch-depth: 0 - id: what - name: Classify the exact change + name: Classify the exact change with trusted policy env: BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + TRUSTED_POLICY_SHA: ${{ github.event.pull_request.base.sha || github.sha }} run: | set -euo pipefail - python3 -I scripts/classify_changes.py \ - --repo . --base "$BASE_SHA" --head "$HEAD_SHA" --github-output "$GITHUB_OUTPUT" \ + # The checked-out head is the input being classified, never the authority that decides which checks it + # needs. Load that policy from the immutable base commit (or the current main push) so a pull request + # cannot edit its own classifier to skip application work. + git show "$TRUSTED_POLICY_SHA:scripts/classify_changes.py" > "$RUNNER_TEMP/classify_changes.py" + python3 -I "$RUNNER_TEMP/classify_changes.py" \ + --repo . --base "$BASE_SHA" --head "$HEAD_SHA" \ > "$RUNNER_TEMP/change-classification.json" - cat "$RUNNER_TEMP/change-classification.json" + jq -r ' + "app=\(.app)", + "site=\(.site)", + "python=\(.python)", + "actions=\(.actions)", + "pages=\(.pages)", + "head_sha=\(.head_sha)", + "tree=\(.tree)", + "fingerprint=\(.app_fingerprint)", + "schema=flowlight-review-v3" + ' "$RUNNER_TEMP/change-classification.json" >> "$GITHUB_OUTPUT" + jq . "$RUNNER_TEMP/change-classification.json" + + evidence: + name: reusable app evidence + needs: changes + if: >- + github.event_name == 'pull_request' && + github.repository == 'xinbetween/flowlight' && + github.event.pull_request.head.repo.full_name == github.repository && + needs.changes.outputs.app == 'true' + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + outputs: + reuse: ${{ steps.reuse.outputs.reuse }} + source_run_id: ${{ steps.reuse.outputs.source_run_id }} + source_run_attempt: ${{ steps.reuse.outputs.source_run_attempt }} + source_artifact_id: ${{ steps.reuse.outputs.source_artifact_id }} + source_artifact_name: ${{ steps.reuse.outputs.source_artifact_name }} + source_artifact_digest: ${{ steps.reuse.outputs.source_artifact_digest }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + # The verifier treats every lookup, metadata, archive, and receipt error as a cache miss. A cache miss only + # costs macOS time; it can never turn missing or malformed evidence into a passing app review. + - id: reuse + name: Find direct executed evidence for the exact full tree + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SOURCE_SHA: ${{ needs.changes.outputs.head_sha }} + SOURCE_TREE: ${{ needs.changes.outputs.tree }} + APP_FINGERPRINT: ${{ needs.changes.outputs.fingerprint }} + CURRENT_RUN_ID: ${{ github.run_id }} + TRUSTED_VERIFIER_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + ACTUAL_SHA=$(git rev-parse 'HEAD^{commit}') + ACTUAL_TREE=$(git rev-parse 'HEAD^{tree}') + [[ "$SOURCE_SHA" == "$ACTUAL_SHA" ]] + [[ "$SOURCE_TREE" == "$ACTUAL_TREE" ]] + # Reuse is an optimization, so the first release that introduces this verifier simply executes every + # current job. Once the verifier exists on main, only those immutable base-commit bytes may authorize a + # cache hit; the pull request's own copy is never executed as evidence policy. + if ! git show "$TRUSTED_VERIFIER_SHA:scripts/workflow_evidence.py" \ + > "$RUNNER_TEMP/workflow_evidence.py" 2>/dev/null; then + echo "reuse=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + python3 -I "$RUNNER_TEMP/workflow_evidence.py" find-reuse \ + --repository "$REPOSITORY" \ + --source-sha "$ACTUAL_SHA" \ + --source-tree "$ACTUAL_TREE" \ + --app-fingerprint "$APP_FINGERPRINT" \ + --current-run-id "$CURRENT_RUN_ID" \ + --github-output "$GITHUB_OUTPUT" # Seconds of Python, so it runs on everything: the site is generated from Swift sources and project.yml as well # as from site/, and "docs/ is out of date" should be caught whichever of those moved. @@ -142,10 +217,45 @@ jobs: done <<< "$(sed -n '/^Shipped:/,/^Planned/p' README.md | grep -o '([0-9]\+\.[0-9]\+\.[0-9]\+)' | tr -d '()')" [ "$bad" -eq 0 ] || exit 1 - project-drift: - name: project drift - needs: changes - if: needs.changes.outputs.app == 'true' + - name: Create the exact validated Pages payload + if: >- + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + github.repository == 'xinbetween/flowlight' + env: + REPOSITORY: ${{ github.repository }} + SOURCE_SHA: ${{ needs.changes.outputs.head_sha }} + SOURCE_TREE: ${{ needs.changes.outputs.tree }} + run: | + set -euo pipefail + python3 -I scripts/workflow_evidence.py create-pages \ + --root docs \ + --output "$RUNNER_TEMP/pages-payload.tar" \ + --repository "$REPOSITORY" \ + --source-sha "$SOURCE_SHA" \ + --source-tree "$SOURCE_TREE" + + - name: Preserve the validated Pages payload + if: >- + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + github.repository == 'xinbetween/flowlight' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: pages-site-${{ needs.changes.outputs.head_sha }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/pages-payload.tar + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + project-drift-execute: + name: project drift execution + needs: [changes, evidence] + if: >- + always() && + needs.changes.result == 'success' && + needs.changes.outputs.app == 'true' && + needs.evidence.outputs.reuse != 'true' runs-on: macos-15 steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -158,10 +268,14 @@ jobs: - name: Generate the project and reject drift run: scripts/check_xcode_project.sh - unit-oldest: - name: unit (oldest Xcode) - needs: changes - if: needs.changes.outputs.app == 'true' + unit-oldest-execute: + name: unit oldest execution + needs: [changes, evidence] + if: >- + always() && + needs.changes.result == 'success' && + needs.changes.outputs.app == 'true' && + needs.evidence.outputs.reuse != 'true' runs-on: macos-15 steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -179,10 +293,14 @@ jobs: timeout-minutes: 40 run: scripts/run-ci-tests.sh - unit-newest: - name: unit (newest Xcode) - needs: changes - if: needs.changes.outputs.app == 'true' + unit-newest-execute: + name: unit newest execution + needs: [changes, evidence] + if: >- + always() && + needs.changes.result == 'success' && + needs.changes.outputs.app == 'true' && + needs.evidence.outputs.reuse != 'true' runs-on: macos-15 steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -201,19 +319,29 @@ jobs: scripts/test-rule-pack-policy.sh scripts/test-swift-warnings.sh scripts/test-codeql-gate.sh + scripts/test-update-cask.sh python3 -I scripts/test_classify_changes.py + python3 -I scripts/test_workflow_evidence.py + python3 -I scripts/check_chart_palette.py + python3 -I scripts/test_chart_palette.py python3 -I scripts/test_check_site.py - python3 scripts/test_release_checks.py + python3 -I scripts/test_release_checks.py + python3 -I scripts/test_release_artifact.py + python3 -I scripts/test_release_workflow.py # Ad-hoc signing: the entitlements that need a Developer ID profile are only exercised by the release build. - name: Test timeout-minutes: 40 run: scripts/run-ci-tests.sh - store: - name: store - needs: changes - if: needs.changes.outputs.app == 'true' + store-execute: + name: store execution + needs: [changes, evidence] + if: >- + always() && + needs.changes.result == 'success' && + needs.changes.outputs.app == 'true' && + needs.evidence.outputs.reuse != 'true' runs-on: macos-15 steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 @@ -253,6 +381,108 @@ jobs: codesign --force --sign - --entitlements Flowlight/Flowlight-AppStore.entitlements "$APP" scripts/check_app_store_product.sh "$APP" + # These current-run gates preserve the required check names even when the expensive implementation was reused. + # A missing or invalid receipt leaves REUSE false and therefore requires the corresponding execution job. + project-drift: + name: project drift + if: always() + needs: [changes, evidence, project-drift-execute] + runs-on: ubuntu-24.04 + steps: + - name: Require executed or reusable project drift evidence + env: + CHANGES_RESULT: ${{ needs.changes.result }} + APP_ROUTE: ${{ needs.changes.outputs.app }} + REUSE: ${{ needs.evidence.outputs.reuse }} + EXECUTION_RESULT: ${{ needs.project-drift-execute.result }} + run: | + set -euo pipefail + [[ "$CHANGES_RESULT" == success ]] + if [[ "$APP_ROUTE" == true ]]; then + if [[ "$REUSE" == true ]]; then + [[ "$EXECUTION_RESULT" == skipped ]] + else + [[ "$EXECUTION_RESULT" == success ]] + fi + else + [[ "$EXECUTION_RESULT" == skipped ]] + fi + + unit-oldest: + name: unit (oldest Xcode) + if: always() + needs: [changes, evidence, unit-oldest-execute] + runs-on: ubuntu-24.04 + steps: + - name: Require executed or reusable oldest-Xcode evidence + env: + CHANGES_RESULT: ${{ needs.changes.result }} + APP_ROUTE: ${{ needs.changes.outputs.app }} + REUSE: ${{ needs.evidence.outputs.reuse }} + EXECUTION_RESULT: ${{ needs.unit-oldest-execute.result }} + run: | + set -euo pipefail + [[ "$CHANGES_RESULT" == success ]] + if [[ "$APP_ROUTE" == true ]]; then + if [[ "$REUSE" == true ]]; then + [[ "$EXECUTION_RESULT" == skipped ]] + else + [[ "$EXECUTION_RESULT" == success ]] + fi + else + [[ "$EXECUTION_RESULT" == skipped ]] + fi + + unit-newest: + name: unit (newest Xcode) + if: always() + needs: [changes, evidence, unit-newest-execute] + runs-on: ubuntu-24.04 + steps: + - name: Require executed or reusable newest-Xcode evidence + env: + CHANGES_RESULT: ${{ needs.changes.result }} + APP_ROUTE: ${{ needs.changes.outputs.app }} + REUSE: ${{ needs.evidence.outputs.reuse }} + EXECUTION_RESULT: ${{ needs.unit-newest-execute.result }} + run: | + set -euo pipefail + [[ "$CHANGES_RESULT" == success ]] + if [[ "$APP_ROUTE" == true ]]; then + if [[ "$REUSE" == true ]]; then + [[ "$EXECUTION_RESULT" == skipped ]] + else + [[ "$EXECUTION_RESULT" == success ]] + fi + else + [[ "$EXECUTION_RESULT" == skipped ]] + fi + + store: + name: store + if: always() + needs: [changes, evidence, store-execute] + runs-on: ubuntu-24.04 + steps: + - name: Require executed or reusable Store evidence + env: + CHANGES_RESULT: ${{ needs.changes.result }} + APP_ROUTE: ${{ needs.changes.outputs.app }} + REUSE: ${{ needs.evidence.outputs.reuse }} + EXECUTION_RESULT: ${{ needs.store-execute.result }} + run: | + set -euo pipefail + [[ "$CHANGES_RESULT" == success ]] + if [[ "$APP_ROUTE" == true ]]; then + if [[ "$REUSE" == true ]]; then + [[ "$EXECUTION_RESULT" == skipped ]] + else + [[ "$EXECUTION_RESULT" == success ]] + fi + else + [[ "$EXECUTION_RESULT" == skipped ]] + fi + # Exact-head evidence proves what was reviewed; this separate PR-only build proves that same head still compiles # when combined with the current base. The trees are intentionally different, so this is not duplicate work. merge-compatibility: @@ -261,9 +491,9 @@ jobs: if: github.event_name == 'pull_request' && needs.changes.outputs.app == 'true' runs-on: macos-15 steps: + # On pull_request, checkout's event default is GitHub's prospective merge commit. Do not construct a ref from + # event data here: CodeQL correctly treats explicit untrusted-ref checkout as a separate attack surface. - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - ref: refs/pull/${{ github.event.number }}/merge - name: Build the prospective merge run: | @@ -276,7 +506,19 @@ jobs: gate: name: Review / gate if: always() - needs: [changes, site, project-drift, unit-oldest, unit-newest, store, merge-compatibility] + needs: + - changes + - evidence + - site + - project-drift-execute + - unit-oldest-execute + - unit-newest-execute + - store-execute + - project-drift + - unit-oldest + - unit-newest + - store + - merge-compatibility runs-on: ubuntu-24.04 steps: - name: Require every applicable review job @@ -294,6 +536,19 @@ jobs: TREE: ${{ needs.changes.outputs.tree }} FINGERPRINT: ${{ needs.changes.outputs.fingerprint }} SCHEMA: ${{ needs.changes.outputs.schema }} + REUSE: ${{ needs.evidence.outputs.reuse }} + SOURCE_RUN_ID: ${{ needs.evidence.outputs.source_run_id }} + SOURCE_RUN_ATTEMPT: ${{ needs.evidence.outputs.source_run_attempt }} + SOURCE_ARTIFACT_ID: ${{ needs.evidence.outputs.source_artifact_id }} + SOURCE_ARTIFACT_NAME: ${{ needs.evidence.outputs.source_artifact_name }} + SOURCE_ARTIFACT_DIGEST: ${{ needs.evidence.outputs.source_artifact_digest }} + PROJECT_DRIFT_EXECUTION: ${{ needs.project-drift-execute.result }} + UNIT_OLDEST_EXECUTION: ${{ needs.unit-oldest-execute.result }} + UNIT_NEWEST_EXECUTION: ${{ needs.unit-newest-execute.result }} + STORE_EXECUTION: ${{ needs.store-execute.result }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + REPOSITORY: ${{ github.repository }} run: | set -euo pipefail [[ "$CHANGES_RESULT" == success ]] || { @@ -311,13 +566,10 @@ jobs: "store:$STORE_RESULT"; do name=${result%%:*} conclusion=${result#*:} - case "$APP_ROUTE:$conclusion" in - true:success|false:skipped) ;; - *) - echo "::error::Application route $APP_ROUTE produced $name result $conclusion." - exit 1 - ;; - esac + [[ "$conclusion" == success ]] || { + echo "::error::Application route $APP_ROUTE produced $name gate result $conclusion." + exit 1 + } done if [[ "$EVENT_NAME" == pull_request && "$APP_ROUTE" == true ]]; then [[ "$MERGE_RESULT" == success ]] || { @@ -330,7 +582,7 @@ jobs: exit 1 } fi - [[ "$SCHEMA" == flowlight-review-v2 ]] || { + [[ "$SCHEMA" == flowlight-review-v3 ]] || { echo "::error::Unexpected review receipt schema: $SCHEMA" exit 1 } @@ -338,15 +590,28 @@ jobs: echo "::error::The review receipt identity is incomplete." exit 1 } + mode=not_applicable + if [[ "$APP_ROUTE" == true ]]; then + mode=executed + if [[ "$REUSE" == true ]]; then + mode=reused + [[ -n "$SOURCE_RUN_ID" && -n "$SOURCE_RUN_ATTEMPT" && \ + -n "$SOURCE_ARTIFACT_ID" && -n "$SOURCE_ARTIFACT_NAME" && \ + -n "$SOURCE_ARTIFACT_DIGEST" ]] || { + echo "::error::Reusable evidence identity is incomplete." + exit 1 + } + fi + fi jq -n \ --arg schema "$SCHEMA" \ - --arg repository "${{ github.repository }}" \ + --arg repository "$REPOSITORY" \ --arg workflow ".github/workflows/ci.yml" \ - --arg event "${{ github.event_name }}" \ + --arg event "$EVENT_NAME" \ --arg source_sha "$HEAD_SHA" \ --arg source_tree "$TREE" \ --arg app_fingerprint "$FINGERPRINT" \ - --arg app_route "$APP_ROUTE" \ + --argjson app_route "$APP_ROUTE" \ --arg changes "$CHANGES_RESULT" \ --arg site "$SITE_RESULT" \ --arg project_drift "$PROJECT_DRIFT_RESULT" \ @@ -354,15 +619,27 @@ jobs: --arg unit_newest "$UNIT_NEWEST_RESULT" \ --arg store "$STORE_RESULT" \ --arg merge_compatibility "$MERGE_RESULT" \ - --arg run_id "${{ github.run_id }}" \ - --arg run_attempt "${{ github.run_attempt }}" \ + --argjson run_id "$RUN_ID" \ + --argjson run_attempt "$RUN_ATTEMPT" \ + --arg mode "$mode" \ + --arg source_run_id "$SOURCE_RUN_ID" \ + --arg source_run_attempt "$SOURCE_RUN_ATTEMPT" \ + --arg source_artifact_id "$SOURCE_ARTIFACT_ID" \ + --arg source_artifact_name "$SOURCE_ARTIFACT_NAME" \ + --arg source_artifact_digest "$SOURCE_ARTIFACT_DIGEST" \ '{schema: $schema, repository: $repository, workflow: $workflow, event: $event, source_sha: $source_sha, source_tree: $source_tree, app_fingerprint: $app_fingerprint, app_route: $app_route, conclusions: {changes: $changes, site: $site, project_drift: $project_drift, unit_oldest: $unit_oldest, unit_newest: $unit_newest, store: $store, merge_compatibility: $merge_compatibility}, - run_id: $run_id, run_attempt: $run_attempt}' \ + run_id: $run_id, run_attempt: $run_attempt, + app_evidence: {mode: $mode, + source: (if $mode == "reused" then + {run_id: ($source_run_id | tonumber), run_attempt: ($source_run_attempt | tonumber), + artifact_id: ($source_artifact_id | tonumber), artifact_name: $source_artifact_name, + artifact_digest: $source_artifact_digest} + else null end)}}' \ > "$RUNNER_TEMP/review-receipt.json" { echo "### Review gate passed" diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ffbcd405..727bc1f4 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,115 +1,121 @@ -# Publishes docs/ to GitHub Pages. -# -# Pages can deploy a branch folder on its own, and did until now. That path runs a workflow GitHub generates -# and we cannot edit, built on actions that are a major version behind — which is where the "Node.js 20 is -# deprecated" warning on every push came from. Doing it here instead means the actions are ours to keep -# current, and the site is only rebuilt when something it serves has changed. +# Deploys only the exact docs/ payload validated by a successful canonical CI push to main. name: Pages on: - push: + workflow_run: + workflows: [CI] branches: [main] - paths: ["docs/**", ".github/workflows/pages.yml"] - workflow_dispatch: + types: [completed] -permissions: - contents: read - deployments: read - pages: write - id-token: write +permissions: {} -# One deployment at a time, and a newer push wins — but never cancel one midway, because a half-finished -# deployment is a half-updated site. +# Serialize the source-is-current check with deployment. An older queued run fails stale after main advances; +# deployments are never cancelled halfway through. concurrency: group: pages cancel-in-progress: false jobs: + prepare: + name: verify Pages provenance + if: >- + github.repository == 'xinbetween/flowlight' && + github.event.workflow_run.repository.full_name == 'xinbetween/flowlight' && + github.event.workflow_run.head_repository.full_name == 'xinbetween/flowlight' && + github.event.workflow_run.path == '.github/workflows/ci.yml' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.status == 'completed' && + github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + outputs: + source_sha: ${{ steps.payload.outputs.source_sha }} + source_tree: ${{ steps.payload.outputs.source_tree }} + source_run_id: ${{ steps.payload.outputs.source_run_id }} + source_artifact_id: ${{ steps.payload.outputs.source_artifact_id }} + source_artifact_digest: ${{ steps.payload.outputs.source_artifact_digest }} + steps: + # No checkout: Pages consumes bytes CI already validated. Fetching the verifier from the source SHA happens + # only after a small independent gate proves that SHA is the canonical main tip and the event is its CI push. + - name: Fetch the verifier from the still-current canonical source + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + SOURCE_REPOSITORY: ${{ github.event.workflow_run.repository.full_name }} + HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name }} + SOURCE_WORKFLOW: ${{ github.event.workflow_run.path }} + SOURCE_EVENT: ${{ github.event.workflow_run.event }} + SOURCE_BRANCH: ${{ github.event.workflow_run.head_branch }} + SOURCE_STATUS: ${{ github.event.workflow_run.status }} + SOURCE_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + SOURCE_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + [[ "$REPOSITORY" == xinbetween/flowlight ]] + [[ "$SOURCE_REPOSITORY" == "$REPOSITORY" ]] + [[ "$HEAD_REPOSITORY" == "$REPOSITORY" ]] + [[ "$SOURCE_WORKFLOW" == .github/workflows/ci.yml ]] + [[ "$SOURCE_EVENT" == push ]] + [[ "$SOURCE_BRANCH" == main ]] + [[ "$SOURCE_STATUS" == completed ]] + [[ "$SOURCE_CONCLUSION" == success ]] + CURRENT_MAIN=$(gh api "repos/$REPOSITORY/git/ref/heads/main" --jq .object.sha) + [[ "$SOURCE_SHA" == "$CURRENT_MAIN" ]] || { + echo "::error::CI source $SOURCE_SHA is stale; main is now $CURRENT_MAIN." + exit 1 + } + gh api -H 'Accept: application/vnd.github.raw+json' \ + "repos/$REPOSITORY/contents/scripts/workflow_evidence.py?ref=$SOURCE_SHA" \ + > "$RUNNER_TEMP/workflow_evidence.py" + + - id: payload + name: Download and verify the exact CI artifact and manifest + env: + GH_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + python3 -I "$RUNNER_TEMP/workflow_evidence.py" consume-pages \ + --event-json "$GITHUB_EVENT_PATH" \ + --repository "$REPOSITORY" \ + --output "$RUNNER_TEMP/verified-site" \ + --github-output "$GITHUB_OUTPUT" + + - name: Upload the verified site for Pages + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: ${{ runner.temp }}/verified-site + deploy: - # A manual run is recovery for the deployed site, never a way to publish an arbitrary branch. - if: ${{ github.event_name == 'push' || github.ref == 'refs/heads/main' }} - # Pinned rather than `ubuntu-latest`: that label moves to Ubuntu 26 in October 2026, and every run has - # been carrying an annotation saying so. A named image means the move happens when we make it, with a - # green run to prove it, rather than on a date somebody else chose. macOS is already pinned the same way. + name: deploy Pages + needs: prepare runs-on: ubuntu-24.04 + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - # The rollback gate compares a push with the commit that was on main before the entire push, which may - # be more than one commit behind HEAD. A two-commit checkout silently compares inside a multi-commit push. - fetch-depth: 0 - - - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - - # CI checks this on every pull request, but this workflow is the one that actually puts the site in front - # of people, and a deploy is not something you can take back from a cache. Seconds of Python for the - # guarantee that nothing broken is ever served. - - name: Verify the official rule-pack catalog + # Recheck after the queued verification job. The repository is public, so this needs no contents permission + # and the deployment job keeps only the two permissions required by Pages itself. + - name: Reject a source that became stale before deployment env: - # gh does not implicitly read github.token; authenticate deployment-history lookups explicitly. - GH_TOKEN: ${{ github.token }} - # Pushes compare with the exact ref main held before the whole push. A manual recovery run instead compares - # with the signed catalog currently served by Pages, so rewritten history cannot roll the public feed back. - PREVIOUS_CATALOG_REF: ${{ github.event.before }} - LIVE_CATALOG_URL: https://flowlight.xinbetween.com/packs/v1/official.json + SOURCE_SHA: ${{ needs.prepare.outputs.source_sha }} run: | - python3 -I scripts/rule_pack_catalog.py compare \ - rule-packs/official-source.json site/packs/v1/official.json - if [ "${{ github.event_name }}" = "push" ]; then - if git cat-file -e "$PREVIOUS_CATALOG_REF:site/packs/v1/official.json" 2>/dev/null; then - python3 -I scripts/rule_pack_catalog.py compare-previous site/packs/v1/official.json \ - --previous-ref "$PREVIOUS_CATALOG_REF" - fi - else - PREVIOUS_CATALOG=$(mktemp) - DEPLOYED_CATALOG_FOUND=false - if DEPLOYMENT_IDS=$(gh api \ - 'repos/${{ github.repository }}/deployments?environment=github-pages&per_page=20' \ - --jq '.[].id'); then - for DEPLOYMENT_ID in $DEPLOYMENT_IDS; do - if ! DEPLOYMENT_STATE=$(gh api \ - "repos/${{ github.repository }}/deployments/$DEPLOYMENT_ID/statuses" \ - --jq '.[0].state // empty'); then - continue - fi - if [ "$DEPLOYMENT_STATE" = "success" ] && \ - DEPLOYED_REF=$(gh api \ - "repos/${{ github.repository }}/deployments/$DEPLOYMENT_ID" --jq '.sha') && \ - gh api -H 'Accept: application/vnd.github.raw+json' \ - "repos/${{ github.repository }}/contents/site/packs/v1/official.json?ref=$DEPLOYED_REF" \ - > "$PREVIOUS_CATALOG"; then - DEPLOYED_CATALOG_FOUND=true - break - fi - done - fi - if [ "$DEPLOYED_CATALOG_FOUND" != "true" ]; then - curl --fail --silent --show-error --connect-timeout 10 --max-time 30 --max-filesize 8388608 \ - --output "$PREVIOUS_CATALOG" "$LIVE_CATALOG_URL" - fi - python3 -I scripts/rule_pack_catalog.py verify "$PREVIOUS_CATALOG" --historical \ - --public-key Flowlight/Resources/RulePacks/official-rule-packs-v1-public.pem \ - --expected-key-id flowlight-rule-packs-2026-01 - python3 -I scripts/rule_pack_catalog.py compare-previous site/packs/v1/official.json \ - --previous "$PREVIOUS_CATALOG" - fi - APP_VERSION=$(sed -n 's/.*MARKETING_VERSION:[[:space:]]*"\(.*\)".*/\1/p' project.yml) - test -n "$APP_VERSION" - python3 -I scripts/rule_pack_catalog.py verify site/packs/v1/official.json \ - --public-key Flowlight/Resources/RulePacks/official-rule-packs-v1-public.pem \ - --expected-key-id flowlight-rule-packs-2026-01 --current-app-version "$APP_VERSION" \ - --extension-protocol 1 --now "$(date +%s)" - - - name: Check the site before serving it - run: python3 scripts/check_site.py --ci + set -euo pipefail + CURRENT_MAIN=$(curl --fail --silent --show-error \ + --connect-timeout 10 --max-time 30 \ + https://api.github.com/repos/xinbetween/flowlight/git/ref/heads/main | jq -r .object.sha) + [[ "$SOURCE_SHA" == "$CURRENT_MAIN" ]] || { + echo "::error::Verified source $SOURCE_SHA is stale; main is now $CURRENT_MAIN." + exit 1 + } - # docs/ is committed and CI already checks it matches a fresh build, so there is nothing to build here. - - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 - with: - path: docs + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - id: deployment uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 990160c3..1db3f949 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,112 +1,153 @@ name: Release -# A version-tag push builds, signs, notarizes and verifies without publishing. After that dry run passes, dispatch -# this workflow for the existing tag with dry_run=false to publish it. The secrets this needs are listed in -# docs/DEVELOPMENT.md, along with what it means to keep a signing identity in -# GitHub. Without them the job fails at the import step rather than publishing something unsigned. -# -# It runs without waiting for an approval. What gates it instead: the release environment only accepts v* tags, -# only the accounts in RELEASE_ACTORS may start one, and a v* tag can't be moved once pushed. +# A version-tag push prepares the only signed bytes this release may publish. It builds, signs, notarizes, +# verifies, attests, and retains one immutable Actions artifact. A later explicit dispatch names that successful +# preparation run and publishes those exact bytes; the publication job cannot access signing credentials. on: push: tags: ["v*"] workflow_dispatch: inputs: - tag: - description: "Existing tag to build (e.g. v0.2.3). Leave empty to build the current ref." - required: false - dry_run: - description: "Build, sign, notarize and verify — but publish nothing." - type: boolean - default: false + prepare_run_id: + description: "Successful tag-push preparation run whose exact bytes should be published" + required: true + type: string -permissions: - contents: write - checks: read - pull-requests: read +permissions: {} concurrency: - group: release-${{ github.ref }} + group: release-${{ github.ref_name }} cancel-in-progress: false jobs: - release: + prepare: + name: Prepare signed release + if: ${{ github.event_name == 'push' }} runs-on: macos-15 timeout-minutes: 90 - # Gated: the signing secrets live in this environment, it takes a human approval to start, and its policy only - # allows v* tags. A push to a branch — or a workflow someone adds to one — can't reach the certificates. environment: release + permissions: + contents: read + checks: read + issues: read + pull-requests: read + actions: read + id-token: write + attestations: write env: TEAM_ID: ${{ secrets.TEAM_ID }} APP_PROFILE: ${{ vars.APP_PROFILE || 'Flowlight Developer ID' }} EXT_PROFILE: ${{ vars.EXT_PROFILE || 'Flowlight Extension Developer ID' }} - # Job-level so the cask step can ask whether it was provided: the secrets context isn't readable from `if`. - TAP_TOKEN: ${{ secrets.TAP_TOKEN }} steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: - ref: ${{ github.event.inputs.tag || github.ref }} + ref: ${{ github.ref }} fetch-depth: 0 - # The environment no longer asks for a click, so this is what stands in its place: a release may only be - # started by an account on this list. Anyone else pushing a v* tag gets a failed run rather than a signed - # build. Set RELEASE_ACTORS (comma-separated) to change who that is. - name: Check who asked for this release env: ALLOWED: ${{ vars.RELEASE_ACTORS || 'blessdyb' }} + EVENT_ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} run: | set -euo pipefail - actor="${{ github.actor }}" - if ! printf '%s' "$ALLOWED" | tr ',' '\n' | tr -d ' ' | grep -qx "$actor"; then - echo "::error::$actor isn't allowed to publish a release. Allowed: $ALLOWED" - exit 1 - fi - echo "Release requested by $actor." + allowed_list=",${ALLOWED// /}," + for candidate in "$EVENT_ACTOR" "$TRIGGERING_ACTOR"; do + if [[ "$allowed_list" != *",$candidate,"* ]]; then + echo "::error::$candidate isn't allowed to prepare or rerun a release. Allowed: $ALLOWED" + exit 1 + fi + done + echo "Release preparation requested by $EVENT_ACTOR and triggered by $TRIGGERING_ACTOR." - name: Read the version and check it matches the tag id: version + env: + EVENT_TAG: ${{ github.ref_name }} + EVENT_SHA: ${{ github.sha }} run: | set -euo pipefail VERSION=$(sed -n 's/.*MARKETING_VERSION:[[:space:]]*"\(.*\)".*/\1/p' project.yml) BUILD=$(sed -n 's/.*CURRENT_PROJECT_VERSION:[[:space:]]*"\(.*\)".*/\1/p' project.yml) - TAG="${{ github.event.inputs.tag || github.ref_name }}" + TAG="$EVENT_TAG" [[ -n "$VERSION" && -n "$BUILD" ]] || { echo "::error::Missing version metadata in project.yml."; exit 1; } + [[ "$TAG" == "v$VERSION" ]] || { echo "::error::Tag $TAG doesn't match MARKETING_VERSION $VERSION."; exit 1; } + [[ "$(git rev-parse "refs/tags/$TAG^{}")" == "$EVENT_SHA" ]] || { + echo "::error::The tag does not peel to the workflow source SHA." + exit 1 + } echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "build=$BUILD" >> "$GITHUB_OUTPUT" echo "tag=$TAG" >> "$GITHUB_OUTPUT" - if [[ "$TAG" != "v$VERSION" ]]; then - echo "::error::Tag $TAG doesn't match MARKETING_VERSION $VERSION in project.yml." - exit 1 - fi - # The release runs at the immutable tag after its pull request gates have passed. Re-running the same test suite - # here used another macOS runner for no new evidence. Resolve the checked-out tag to its commit instead, then - # require the latest trusted CI and CodeQL results plus an open or merged main-targeting pull request at that SHA. - name: Require reviewed checks for the exact release commit env: GH_TOKEN: ${{ github.token }} + RELEASE_REVIEWERS: ${{ vars.RELEASE_REVIEWERS || 'blessdyb' }} run: | set -euo pipefail RELEASE_SHA=$(git rev-parse HEAD) CHECKS="$RUNNER_TEMP/release-checks.json" PULLS="$RUNNER_TEMP/release-pulls.json" - gh api --paginate --slurp \ - "repos/${{ github.repository }}/commits/$RELEASE_SHA/check-runs?per_page=100" > "$CHECKS" - gh api --paginate --slurp -H 'Accept: application/vnd.github+json' \ - "repos/${{ github.repository }}/commits/$RELEASE_SHA/pulls?per_page=100" > "$PULLS" - python3 -I scripts/check_release_checks.py \ - --repo "${{ github.repository }}" --sha "$RELEASE_SHA" --checks "$CHECKS" --pulls "$PULLS" + RUNS="$RUNNER_TEMP/release-workflow-runs.json" + COMMENTS="$RUNNER_TEMP/release-review-comments.json" + COMMENTS_DIR="$RUNNER_TEMP/release-review-comment-pages" + EVIDENCE="$RUNNER_TEMP/review-evidence.json" + mkdir -p "$COMMENTS_DIR" + verified=false + for attempt in 1 2 3 4 5 6; do + gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/commits/$RELEASE_SHA/check-runs?per_page=100" > "$CHECKS" + gh api --paginate --slurp -H 'Accept: application/vnd.github+json' \ + "repos/$GITHUB_REPOSITORY/commits/$RELEASE_SHA/pulls?per_page=100" > "$PULLS" + gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$RELEASE_SHA&event=pull_request&per_page=100" > "$RUNS" + + pull_numbers=$(jq -r --arg sha "$RELEASE_SHA" ' + .[][] + | select(.head.sha == $sha and .base.ref == "main") + | select(.state == "open" or .merged_at != null) + | .number + ' "$PULLS") + comment_pages=() + for pull_number in $pull_numbers; do + [[ "$pull_number" =~ ^[0-9]+$ ]] || { echo "::error::Invalid pull request number."; exit 1; } + page="$COMMENTS_DIR/$pull_number.json" + gh api --paginate --slurp -H 'Accept: application/vnd.github+json' \ + "repos/$GITHUB_REPOSITORY/issues/$pull_number/comments?per_page=100" > "$page" + comment_pages+=("$page") + done + if (( ${#comment_pages[@]} == 0 )); then + jq -n '[]' > "$COMMENTS" + else + jq -s '[.[][]]' "${comment_pages[@]}" > "$COMMENTS" + fi + + if python3 -I scripts/check_release_checks.py \ + --repo "$GITHUB_REPOSITORY" --sha "$RELEASE_SHA" \ + --checks "$CHECKS" --pulls "$PULLS" --runs "$RUNS" --comments "$COMMENTS" \ + --reviewers "$RELEASE_REVIEWERS" --evidence-output "$EVIDENCE"; then + verified=true + break + fi + if [[ "$attempt" -lt 6 ]]; then + delay=$((attempt * 10)) + echo "Release evidence is not complete yet; retrying in ${delay}s (attempt $attempt/6)." + sleep "$delay" + fi + done + [[ "$verified" == true ]] || { echo "::error::Release evidence did not become complete."; exit 1; } - # The newest Xcode on the image, named in the log so a release says which compiler built it. - name: Select Xcode run: | set -euo pipefail sudo xcode-select -s "$(ls -d /Applications/Xcode*.app | sort -V | tail -1)" xcodebuild -version - # The committed project is the exact graph reviewed by CI and selected by Xcode Cloud. Verify its release - # metadata before consuming it so a stale project can never publish under a newer project.yml/tag. - name: Check committed project version + env: + EXPECTED_VERSION: ${{ steps.version.outputs.version }} + EXPECTED_BUILD: ${{ steps.version.outputs.build }} run: | set -euo pipefail for target in Flowlight FlowlightAppStore; do @@ -114,13 +155,12 @@ jobs: xcodebuild -project Flowlight.xcodeproj -target "$target" -configuration Release -showBuildSettings > "$settings" project_version=$(sed -n 's/^[[:space:]]*MARKETING_VERSION = //p' "$settings") project_build=$(sed -n 's/^[[:space:]]*CURRENT_PROJECT_VERSION = //p' "$settings") - if [[ "$project_version" != "${{ steps.version.outputs.version }}" || "$project_build" != "${{ steps.version.outputs.build }}" ]]; then - echo "::error::$target committed project is $project_version/$project_build; project.yml is ${{ steps.version.outputs.version }}/${{ steps.version.outputs.build }}." + if [[ "$project_version" != "$EXPECTED_VERSION" || "$project_build" != "$EXPECTED_BUILD" ]]; then + echo "::error::$target committed project is $project_version/$project_build; project.yml is $EXPECTED_VERSION/$EXPECTED_BUILD." exit 1 fi done - # A keychain of its own, unlocked only for this job, so nothing lands in the runner's login keychain. - name: Import the signing certificates env: APP_CERTIFICATE_P12: ${{ secrets.APP_CERTIFICATE_P12 }} @@ -142,7 +182,6 @@ jobs: -T /usr/bin/codesign -T /usr/bin/productsign -T /usr/bin/productbuild rm -f "$RUNNER_TEMP/$name.p12" done - # Lets codesign use the keys without a UI prompt the runner could never answer. security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null security list-keychains -d user -s "$KEYCHAIN" login.keychain-db security find-identity -v -p codesigning "$KEYCHAIN" @@ -153,7 +192,6 @@ jobs: EXT_PROVISIONING_PROFILE: ${{ secrets.EXT_PROVISIONING_PROFILE }} run: | set -euo pipefail - # Xcode 16 reads this directory; the older location is written too, in case a tool still looks there. for dir in "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" "$HOME/Library/MobileDevice/Provisioning Profiles"; do mkdir -p "$dir" printf '%s' "$APP_PROVISIONING_PROFILE" | base64 --decode > "$dir/flowlight-app.provisionprofile" @@ -162,7 +200,7 @@ jobs: security cms -D -i "$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles/flowlight-app.provisionprofile" \ | plutil -extract Name raw - - - name: Build, sign and notarize + - name: Build, sign and notarize exactly once env: DMG_SIGN_IDENTITY: ${{ secrets.DMG_SIGN_IDENTITY }} INSTALLER_IDENTITY: ${{ secrets.INSTALLER_IDENTITY }} @@ -174,11 +212,9 @@ jobs: NOTARY_PASSWORD: ${{ secrets.NOTARY_PASSWORD }} run: | set -euo pipefail - # Without these the scripts quietly produce an unsigned build, which is worse than failing here. for name in TEAM_ID DMG_SIGN_IDENTITY INSTALLER_IDENTITY; do [[ -n "${!name:-}" ]] || { echo "::error::Secret $name is not set."; exit 1; } done - # Either notarization credential is fine; having neither is not. if [[ -n "${NOTARY_KEY_P8:-}" && -n "${NOTARY_KEY_ID:-}" && -n "${NOTARY_ISSUER:-}" ]]; then export NOTARY_KEY="$RUNNER_TEMP/notary.p8" printf '%s' "$NOTARY_KEY_P8" | base64 --decode > "$NOTARY_KEY" @@ -186,107 +222,413 @@ jobs: elif [[ -n "${NOTARY_APPLE_ID:-}" && -n "${NOTARY_TEAM_ID:-}" && -n "${NOTARY_PASSWORD:-}" ]]; then echo "Notarizing with an Apple ID and app-specific password." else - echo "::error::Set NOTARY_KEY_P8 + NOTARY_KEY_ID + NOTARY_ISSUER, or NOTARY_APPLE_ID + NOTARY_TEAM_ID + NOTARY_PASSWORD." + echo "::error::Set an App Store Connect notarization key or the complete Apple ID credential set." exit 1 fi - # build-dmg.sh runs build-signed.sh itself; build-pkg.sh then reuses build/export/Flowlight.app. scripts/build-dmg.sh REUSE_EXPORT=1 scripts/build-pkg.sh rm -f "${NOTARY_KEY:-}" - - name: Check Gatekeeper accepts what was built + - name: Verify the final signed products + env: + RELEASE_VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail - VERSION="${{ steps.version.outputs.version }}" + VERSION="$RELEASE_VERSION" + PKG="build/Flowlight-$VERSION.pkg" + [[ -f build/Flowlight.dmg && -f "$PKG" ]] || { echo "::error::Expected DMG or PKG is missing."; exit 1; } + [[ "$(find build -maxdepth 1 -type f -name 'Flowlight*.dmg' | wc -l | tr -d ' ')" == 1 ]] || { + echo "::error::Expected exactly one Flowlight DMG." + exit 1 + } + [[ "$(find build -maxdepth 1 -type f -name 'Flowlight*.pkg' | wc -l | tr -d ' ')" == 1 ]] || { + echo "::error::Expected exactly one Flowlight PKG." + exit 1 + } spctl -a -vvv -t open --context context:primary-signature build/Flowlight.dmg - spctl -a -vvv -t install "build/Flowlight-$VERSION.pkg" - hdiutil attach -nobrowse -quiet build/Flowlight.dmg - spctl -a -vvv /Volumes/Flowlight/Flowlight.app - hdiutil detach /Volumes/Flowlight -quiet - # Plain file names, because this file is published as an asset and the updater looks up the disk - # image by name. `cd build` rather than `sed` so the names in the file are the names on the release. - ( cd build && shasum -a 256 Flowlight.dmg "Flowlight-$VERSION.pkg" | tee SHA256SUMS.txt ) + spctl -a -vvv -t install "$PKG" + xcrun stapler validate build/Flowlight.dmg + xcrun stapler validate "$PKG" + MOUNT="$RUNNER_TEMP/flowlight-release-mount" + mkdir "$MOUNT" + hdiutil attach -nobrowse -readonly -mountpoint "$MOUNT" build/Flowlight.dmg + codesign --verify --deep --strict --verbose=2 "$MOUNT/Flowlight.app" + spctl -a -vvv "$MOUNT/Flowlight.app" + xcrun stapler validate "$MOUNT/Flowlight.app" + hdiutil detach "$MOUNT" -quiet + ( + cd build + shasum -a 256 Flowlight.dmg "Flowlight-$VERSION.pkg" > SHA256SUMS.txt + ) - - name: Publish the release - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true' }} + - name: Prepare release notes and provenance manifest env: GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ steps.version.outputs.version }} + RELEASE_BUILD: ${{ steps.version.outputs.build }} + RELEASE_TAG: ${{ steps.version.outputs.tag }} run: | set -euo pipefail - VERSION="${{ steps.version.outputs.version }}" - TAG="${{ steps.version.outputs.tag }}" + VERSION="$RELEASE_VERSION" NOTES="packaging/release-notes/$VERSION.md" - ARGS=(--title "Flowlight $VERSION") - # A hand-written file wins; otherwise the notes are built from the entry already written on the - # releases page. Falling through to --generate-notes produces one "Full Changelog" link, which is what - # the Software Update window showed for every release from 0.6.5 to 0.7.1. - if [[ ! -f "$NOTES" ]] && python3 scripts/release_notes.py "$VERSION" > "$RUNNER_TEMP/from-site.md"; then - NOTES="$RUNNER_TEMP/from-site.md" - fi if [[ -f "$NOTES" ]]; then - # The checksums belong in the notes, and they're only known once the artifacts exist. - { cat "$NOTES"; echo; echo '### Checksums'; echo; echo '```'; cat build/SHA256SUMS.txt; echo '```'; } > "$RUNNER_TEMP/notes.md" - ARGS+=(--notes-file "$RUNNER_TEMP/notes.md") + cp "$NOTES" "$RUNNER_TEMP/base-notes.md" + elif python3 -I scripts/release_notes.py "$VERSION" > "$RUNNER_TEMP/base-notes.md"; then + : else - echo "::error::No notes for $VERSION — neither $NOTES nor an entry on the releases page." + echo "::error::No release notes for $VERSION in packaging or the generated site." exit 1 fi - # SHA256SUMS.txt is an asset, not just a paragraph in the notes. The updater refuses a download it - # cannot verify, and until 0.9.0 this file existed only inside the release body — where nothing can - # read it — so the verification was decorative and then, once it started failing closed, fatal. - gh release create "$TAG" "${ARGS[@]}" build/Flowlight.dmg "build/Flowlight-$VERSION.pkg" build/SHA256SUMS.txt + { + cat "$RUNNER_TEMP/base-notes.md" + echo + echo '### Checksums' + echo + echo '```' + cat build/SHA256SUMS.txt + echo '```' + } > build/RELEASE-NOTES.md + WORKFLOW_ID=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/release.yml" --jq .id) + python3 -I scripts/release_artifact.py create-manifest \ + --bundle build \ + --repository "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" \ + --source-sha "$GITHUB_SHA" \ + --source-tree "$(git rev-parse 'HEAD^{tree}')" \ + --version "$VERSION" \ + --build "$RELEASE_BUILD" \ + --workflow-id "$WORKFLOW_ID" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" \ + --evidence "$RUNNER_TEMP/review-evidence.json" + python3 -I scripts/release_artifact.py verify-bundle \ + --bundle build \ + --repository "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" \ + --source-sha "$GITHUB_SHA" \ + --version "$VERSION" \ + --build "$RELEASE_BUILD" \ + --workflow-id "$WORKFLOW_ID" \ + --run-id "$GITHUB_RUN_ID" \ + --run-attempt "$GITHUB_RUN_ATTEMPT" + + - name: Attest the prepared files + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0 + with: + subject-path: | + build/Flowlight.dmg + build/Flowlight-${{ steps.version.outputs.version }}.pkg + build/SHA256SUMS.txt + build/RELEASE-NOTES.md + build/RELEASE-MANIFEST.json + + - name: Preserve the immutable prepared release + id: artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: flowlight-release-${{ steps.version.outputs.tag }}-${{ github.run_id }}-${{ github.run_attempt }} + path: | + build/Flowlight.dmg + build/Flowlight-${{ steps.version.outputs.version }}.pkg + build/SHA256SUMS.txt + build/RELEASE-NOTES.md + build/RELEASE-MANIFEST.json + if-no-files-found: error + retention-days: 90 + compression-level: 0 - # The updater refuses a download whose checksum it can't find, so a release without this file is one - # nobody can install from inside the app. That is exactly what shipped from the beginning until 0.9.0: - # the checksums were written into the release body, where nothing can read them, and the missing asset - # was invisible because verification used to pass when it found nothing. Checked against the published - # release rather than the build directory, because what matters is what people can actually download. - - name: The release must carry the checksums the updater looks for - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true' }} + - name: Record the publication handoff env: - GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.version.outputs.tag }} + ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }} + ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }} + run: | + set -euo pipefail + { + echo "### Signed release prepared once" + echo + echo "- Tag: \`$RELEASE_TAG\`" + echo "- Source: \`$GITHUB_SHA\`" + echo "- Preparation run: \`$GITHUB_RUN_ID\` (attempt $GITHUB_RUN_ATTEMPT)" + echo "- Artifact ID: \`$ARTIFACT_ID\`" + echo "- Artifact archive digest: \`$ARTIFACT_DIGEST\`" + echo + echo '```sh' + echo "gh workflow run Release --ref $RELEASE_TAG -f prepare_run_id=$GITHUB_RUN_ID" + echo '```' + echo + cat build/SHA256SUMS.txt + } >> "$GITHUB_STEP_SUMMARY" + + publish: + name: Publish prepared release + if: ${{ github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: release-publish + permissions: + actions: read + attestations: read + contents: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + persist-credentials: false + + - name: Check publication identity and exact tag + id: version + env: + ALLOWED: ${{ vars.RELEASE_ACTORS || 'blessdyb' }} + EVENT_ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + EVENT_REF: ${{ github.ref }} + EVENT_TAG: ${{ github.ref_name }} + EVENT_SHA: ${{ github.sha }} + PREPARE_RUN_ID: ${{ inputs.prepare_run_id }} run: | set -euo pipefail - TAG="${{ steps.version.outputs.tag }}" - names=$(gh release view "$TAG" --json assets -q '.assets[].name') - echo "$names" - for required in SHA256SUMS.txt Flowlight.dmg; do - grep -qx "$required" <<< "$names" || { - echo "::error::$TAG has no $required. The in-app updater looks for it by name and refuses a" - echo "::error::download it cannot verify, so this release would be uninstallable from the app." + allowed_list=",${ALLOWED// /}," + for candidate in "$EVENT_ACTOR" "$TRIGGERING_ACTOR"; do + if [[ "$allowed_list" != *",$candidate,"* ]]; then + echo "::error::$candidate isn't allowed to publish or rerun a release. Allowed: $ALLOWED" exit 1 - } + fi done - # And the file has to name the disk image, not merely exist: the updater looks the name up inside it. - gh release download "$TAG" --pattern SHA256SUMS.txt --dir "$RUNNER_TEMP" --clobber - grep -q " Flowlight.dmg$" "$RUNNER_TEMP/SHA256SUMS.txt" || { - echo "::error::SHA256SUMS.txt has no line for Flowlight.dmg, so the updater can't verify it." + [[ "$EVENT_REF" == refs/tags/v* ]] || { echo "::error::Publication must be dispatched from a v* tag."; exit 1; } + [[ "$PREPARE_RUN_ID" =~ ^[0-9]+$ ]] || { echo "::error::prepare_run_id must contain decimal digits only."; exit 1; } + VERSION=$(sed -n 's/.*MARKETING_VERSION:[[:space:]]*"\(.*\)".*/\1/p' project.yml) + BUILD=$(sed -n 's/.*CURRENT_PROJECT_VERSION:[[:space:]]*"\(.*\)".*/\1/p' project.yml) + TAG="$EVENT_TAG" + RELEASE_SHA=$(git rev-parse HEAD) + [[ "$TAG" == "v$VERSION" ]] || { echo "::error::Tag $TAG doesn't match MARKETING_VERSION $VERSION."; exit 1; } + [[ "$RELEASE_SHA" == "$EVENT_SHA" ]] || { echo "::error::Checkout SHA differs from the dispatch SHA."; exit 1; } + [[ "$(git rev-parse "refs/tags/$TAG^{}")" == "$RELEASE_SHA" ]] || { echo "::error::The tag does not peel to the dispatch SHA."; exit 1; } + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "build=$BUILD" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" + + - name: Verify the selected preparation run + id: prepared + env: + GH_TOKEN: ${{ github.token }} + PREPARE_RUN_ID: ${{ inputs.prepare_run_id }} + RELEASE_TAG: ${{ steps.version.outputs.tag }} + RELEASE_SHA: ${{ steps.version.outputs.sha }} + run: | + set -euo pipefail + RUN_ID="$PREPARE_RUN_ID" + RUN_JSON="$RUNNER_TEMP/prepare-run.json" + ARTIFACTS_JSON="$RUNNER_TEMP/prepare-artifacts.json" + RESULT_JSON="$RUNNER_TEMP/prepare-result.json" + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID" > "$RUN_JSON" + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/artifacts?per_page=100" > "$ARTIFACTS_JSON" + WORKFLOW_ID=$(gh api "repos/$GITHUB_REPOSITORY/actions/workflows/release.yml" --jq .id) + python3 -I scripts/release_artifact.py verify-run \ + --run-json "$RUN_JSON" \ + --artifacts-json "$ARTIFACTS_JSON" \ + --repository "$GITHUB_REPOSITORY" \ + --workflow-id "$WORKFLOW_ID" \ + --run-id "$RUN_ID" \ + --tag "$RELEASE_TAG" \ + --source-sha "$RELEASE_SHA" > "$RESULT_JSON" + echo "workflow_id=$WORKFLOW_ID" >> "$GITHUB_OUTPUT" + echo "artifact_id=$(jq -r .artifact_id "$RESULT_JSON")" >> "$GITHUB_OUTPUT" + echo "artifact_digest=$(jq -r .artifact_digest "$RESULT_JSON")" >> "$GITHUB_OUTPUT" + echo "run_attempt=$(jq -r .run_attempt "$RESULT_JSON")" >> "$GITHUB_OUTPUT" + + - name: Download and verify the immutable artifact archive + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ steps.prepared.outputs.artifact_id }} + ARTIFACT_DIGEST: ${{ steps.prepared.outputs.artifact_digest }} + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + ARCHIVE="$RUNNER_TEMP/prepared-release.zip" + PREPARED="$RUNNER_TEMP/prepared-release" + gh api "repos/$GITHUB_REPOSITORY/actions/artifacts/$ARTIFACT_ID/zip" > "$ARCHIVE" + EXPECTED="$ARTIFACT_DIGEST" + ACTUAL="sha256:$(sha256sum "$ARCHIVE" | cut -d' ' -f1)" + [[ "$ACTUAL" == "$EXPECTED" ]] || { + echo "::error::Artifact archive digest is $ACTUAL, expected $EXPECTED." exit 1 } + python3 -I scripts/release_artifact.py extract-archive \ + --archive "$ARCHIVE" --destination "$PREPARED" --version "$RELEASE_VERSION" - # The cask points at the release that was just published, so this runs last. - - name: Update the Homebrew cask - if: ${{ env.TAP_TOKEN != '' && github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true' }} + - name: Verify bundle identity and provenance attestations + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.version.outputs.tag }} + RELEASE_SHA: ${{ steps.version.outputs.sha }} + RELEASE_VERSION: ${{ steps.version.outputs.version }} + RELEASE_BUILD: ${{ steps.version.outputs.build }} + WORKFLOW_ID: ${{ steps.prepared.outputs.workflow_id }} + PREPARE_RUN_ID: ${{ inputs.prepare_run_id }} + PREPARE_RUN_ATTEMPT: ${{ steps.prepared.outputs.run_attempt }} run: | set -euo pipefail - git clone "https://x-access-token:$TAP_TOKEN@github.com/xinbetween/homebrew-tap.git" "$RUNNER_TEMP/tap" + PREPARED="$RUNNER_TEMP/prepared-release" + python3 -I scripts/release_artifact.py verify-bundle \ + --bundle "$PREPARED" \ + --repository "$GITHUB_REPOSITORY" \ + --tag "$RELEASE_TAG" \ + --source-sha "$RELEASE_SHA" \ + --version "$RELEASE_VERSION" \ + --build "$RELEASE_BUILD" \ + --workflow-id "$WORKFLOW_ID" \ + --run-id "$PREPARE_RUN_ID" \ + --run-attempt "$PREPARE_RUN_ATTEMPT" + for file in "$PREPARED"/*; do + gh attestation verify "$file" \ + --repo "$GITHUB_REPOSITORY" \ + --source-ref "refs/tags/$RELEASE_TAG" \ + --source-digest "$RELEASE_SHA" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" + done + + - name: Create or resume the exact release candidate + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.version.outputs.tag }} + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + TAG="$RELEASE_TAG" + VERSION="$RELEASE_VERSION" + PREPARED="$RUNNER_TEMP/prepared-release" + if gh release view "$TAG" >/dev/null 2>&1; then + if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" == true ]]; then + echo "Resuming the existing draft for $TAG." + gh release edit "$TAG" --title "Flowlight $VERSION" --notes-file "$PREPARED/RELEASE-NOTES.md" + gh release upload "$TAG" --clobber \ + "$PREPARED/Flowlight.dmg" \ + "$PREPARED/Flowlight-$VERSION.pkg" \ + "$PREPARED/SHA256SUMS.txt" \ + "$PREPARED/RELEASE-MANIFEST.json" + else + echo "A public release already exists for $TAG; it will be verified without mutation." + fi + else + gh release create "$TAG" \ + --verify-tag \ + --draft \ + --latest \ + --title "Flowlight $VERSION" \ + --notes-file "$PREPARED/RELEASE-NOTES.md" \ + "$PREPARED/Flowlight.dmg" \ + "$PREPARED/Flowlight-$VERSION.pkg" \ + "$PREPARED/SHA256SUMS.txt" \ + "$PREPARED/RELEASE-MANIFEST.json" + fi + + - name: Re-download and verify every release asset + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.version.outputs.tag }} + RELEASE_SHA: ${{ steps.version.outputs.sha }} + RELEASE_VERSION: ${{ steps.version.outputs.version }} + RELEASE_BUILD: ${{ steps.version.outputs.build }} + WORKFLOW_ID: ${{ steps.prepared.outputs.workflow_id }} + PREPARE_RUN_ID: ${{ inputs.prepare_run_id }} + PREPARE_RUN_ATTEMPT: ${{ steps.prepared.outputs.run_attempt }} + run: | + set -euo pipefail + TAG="$RELEASE_TAG" + VERSION="$RELEASE_VERSION" + PREPARED="$RUNNER_TEMP/prepared-release" + CANDIDATE="$RUNNER_TEMP/release-candidate" + mkdir "$CANDIDATE" + gh release view "$TAG" --json assets -q '.assets[].name' > "$RUNNER_TEMP/candidate-assets.txt" + sort "$RUNNER_TEMP/candidate-assets.txt" > "$RUNNER_TEMP/candidate-assets-sorted.txt" + { + echo "Flowlight-$VERSION.pkg" + echo "Flowlight.dmg" + echo "RELEASE-MANIFEST.json" + echo "SHA256SUMS.txt" + } > "$RUNNER_TEMP/expected-assets.txt" + cmp "$RUNNER_TEMP/expected-assets.txt" "$RUNNER_TEMP/candidate-assets-sorted.txt" + gh release download "$TAG" --dir "$CANDIDATE" + python3 -I scripts/release_artifact.py verify-bundle \ + --bundle "$CANDIDATE" \ + --public \ + --repository "$GITHUB_REPOSITORY" \ + --tag "$TAG" \ + --source-sha "$RELEASE_SHA" \ + --version "$VERSION" \ + --build "$RELEASE_BUILD" \ + --workflow-id "$WORKFLOW_ID" \ + --run-id "$PREPARE_RUN_ID" \ + --run-attempt "$PREPARE_RUN_ATTEMPT" + cmp "$PREPARED/Flowlight.dmg" "$CANDIDATE/Flowlight.dmg" + cmp "$PREPARED/Flowlight-$VERSION.pkg" "$CANDIDATE/Flowlight-$VERSION.pkg" + cmp "$PREPARED/SHA256SUMS.txt" "$CANDIDATE/SHA256SUMS.txt" + cmp "$PREPARED/RELEASE-MANIFEST.json" "$CANDIDATE/RELEASE-MANIFEST.json" + gh release view "$TAG" --json body --jq .body > "$RUNNER_TEMP/candidate-body.md" + python3 -I - "$PREPARED/RELEASE-NOTES.md" "$RUNNER_TEMP/candidate-body.md" <<'PY' + import pathlib, sys + expected, actual = (pathlib.Path(value).read_text().rstrip("\n") for value in sys.argv[1:]) + if actual != expected: + raise SystemExit("release body differs from the prepared release notes") + PY + + - name: Publish the verified draft + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.version.outputs.tag }} + run: | + set -euo pipefail + TAG="$RELEASE_TAG" + if [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" == true ]]; then + gh release edit "$TAG" --draft=false --latest + fi + [[ "$(gh release view "$TAG" --json isDraft --jq .isDraft)" == false ]] || { + echo "::error::$TAG is still a draft after publication." + exit 1 + } + + - name: Update Homebrew from the verified DMG + env: + TAP_TOKEN: ${{ secrets.TAP_TOKEN }} + RELEASE_VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + if [[ -z "$TAP_TOKEN" ]]; then + echo "TAP_TOKEN is not configured in release-publish; GitHub publication is complete and Homebrew must be updated manually." + exit 0 + fi + VERSION="$RELEASE_VERSION" + PREPARED="$RUNNER_TEMP/prepared-release" + mkdir build + cp "$PREPARED/Flowlight.dmg" build/Flowlight.dmg + export GH_TOKEN="$TAP_TOKEN" + gh auth setup-git + gh repo clone xinbetween/homebrew-tap "$RUNNER_TEMP/tap" git -C "$RUNNER_TEMP/tap" config user.name "github-actions[bot]" git -C "$RUNNER_TEMP/tap" config user.email "41898282+github-actions[bot]@users.noreply.github.com" - TAP="$RUNNER_TEMP/tap" scripts/update-cask.sh + VERSION="$VERSION" REQUIRE_LOCAL_DMG=1 TAP="$RUNNER_TEMP/tap" scripts/update-cask.sh + EXPECTED=$(jq -r '.assets[] | select(.name == "Flowlight.dmg") | .sha256' "$PREPARED/RELEASE-MANIFEST.json") + ACTUAL=$(sed -n 's/^ sha256 "\(.*\)"/\1/p' "$RUNNER_TEMP/tap/Casks/flowlight.rb") + [[ "$ACTUAL" == "$EXPECTED" ]] || { echo "::error::Homebrew checksum differs from the prepared manifest."; exit 1; } git -C "$RUNNER_TEMP/tap" push - - name: Say what a dry run produced - if: ${{ github.event_name == 'push' || github.event.inputs.dry_run == 'true' }} + - name: Record the published identity + env: + RELEASE_TAG: ${{ steps.version.outputs.tag }} + RELEASE_SHA: ${{ steps.version.outputs.sha }} + PREPARE_RUN_ID: ${{ inputs.prepare_run_id }} + ARTIFACT_DIGEST: ${{ steps.prepared.outputs.artifact_digest }} run: | set -euo pipefail - VERSION="${{ steps.version.outputs.version }}" { - echo "### Dry run: nothing was published" + echo "### Published the prepared release" echo - echo "Signed, notarized and accepted by Gatekeeper:" - echo '```' - cat build/SHA256SUMS.txt - echo '```' - echo "Dispatch this workflow for \`${{ steps.version.outputs.tag }}\` with **dry_run=false** to publish." + echo "- Tag: \`$RELEASE_TAG\`" + echo "- Source: \`$RELEASE_SHA\`" + echo "- Preparation run: \`$PREPARE_RUN_ID\`" + echo "- Prepared artifact digest: \`$ARTIFACT_DIGEST\`" + echo "- No build, signing, notarization, or Apple credential step ran during publication." } >> "$GITHUB_STEP_SUMMARY" - echo "Dry run complete — Flowlight $VERSION built and verified, nothing published." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b023153a..6a5f2f1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ Internals are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). Every release follows this non-negotiable sequence: ```text -feature branch → release branch → reviewed green PR → immutable tag → signed dry run → publish and verify → merge to main +feature branch → release branch → reviewed green PR → immutable tag → signed preparation → publish exact bytes and verify → merge to main ``` `main` is what has shipped, not what is being built. Start each feature from it and rebase it onto it; never merge @@ -65,28 +65,32 @@ git merge --ff-only feature/relaunch-through-proxy git push -u origin release/0.13.4 gh pr create --base main --title "Flowlight 0.13.4" -# wait for review and every required PR check to pass +# after reviewing the final head, a login configured in RELEASE_REVIEWERS must post +# `Reviewed-Commit: ` as a PR comment; the PR author's editable body is not evidence +# wait for that authenticated receipt and every required PR check to pass git tag -a v0.13.4 -m "Flowlight 0.13.4" -git push origin v0.13.4 # automatically runs the signed dry run; it cannot publish -# inspect that successful signed/notarized/Gatekeeper run, then publish from the same tag: -gh workflow run Release --ref v0.13.4 -f tag=v0.13.4 -# verify the GitHub release, DMG, PKG and SHA256SUMS.txt before the final merge +git push origin v0.13.4 # prepares, attests and retains the only bytes publication may use +# inspect that successful preparation, then publish its exact artifact from the same tag: +gh workflow run Release --ref v0.13.4 -f prepare_run_id= +# verify the GitHub release, DMG, PKG, SHA256SUMS.txt and RELEASE-MANIFEST.json before the final merge gh pr merge --merge --delete-branch ``` -**Tags are immutable.** Never retag, force-push or move a version after it has been pushed. If a tag is wrong or a -release needs another change, bump the patch version and begin a new release branch and tag. +**Release-tag creation is restricted and tags are immutable.** Only the designated release account may create a +`v*` tag. Never retag, force-push, or move a version after it has been pushed. If a tag is wrong or a release needs +another change, bump the patch version and begin a new release branch and tag. **The PR precedes the tag, and the merge follows publication.** Review and green checks protect the exact tree that will be signed. Publishing before merging keeps the GitHub Pages site from announcing a download that does not exist: `docs/` is served from `main`, its Download button resolves to the latest GitHub release, CI requires `docs/` to match `site/`, and the site builder reads `MARKETING_VERSION`. -A tag push starts `release.yml` in dry-run-only mode; push-triggered runs cannot publish. It must prove tests, signing, -notarization and Gatekeeper verification before an explicit production dispatch for that immutable tag. After production -succeeds, verify the release page and downloaded DMG, PKG and -`SHA256SUMS.txt` checksums, then merge the release PR into `main`. +A tag push starts `release.yml` in preparation-only mode; push-triggered runs cannot publish. It verifies reviewed +exact-head evidence, builds/signs/notarizes once, passes Gatekeeper, records a manifest, attests every prepared file, and +retains one immutable Actions artifact. The explicit publication dispatch names that successful run, has no signing or +notarization credentials, and can only verify and publish those bytes. After it succeeds, verify the release page and +downloaded DMG, PKG, `SHA256SUMS.txt`, and `RELEASE-MANIFEST.json`, then merge the release PR into `main`. [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#release-branches) documents the signing inputs and workflow internals. diff --git a/Flowlight.xcodeproj/project.pbxproj b/Flowlight.xcodeproj/project.pbxproj index 085efa62..73e077d5 100644 --- a/Flowlight.xcodeproj/project.pbxproj +++ b/Flowlight.xcodeproj/project.pbxproj @@ -61,9 +61,11 @@ 1F8686DCB3451158B115A8FD /* AgentLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 440DB80190A1847AC834210D /* AgentLauncher.swift */; }; 2072AA79A8637D84267BE43F /* MCPDomainsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34AD2BFFA35727FB39287960 /* MCPDomainsTests.swift */; }; 216BDAB2AB83A06B61EE6325 /* InstalledApps.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67F3203B4BE2D664F3B1EC9 /* InstalledApps.swift */; }; + 21B9E226D66289FBC7175C11 /* PerformanceBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5E1A246D41B7421BB0C2FC8 /* PerformanceBudgetTests.swift */; }; 21D7D0DF6C5DF97EB3D1EFD9 /* RulePackCatalogComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = E858E52F162361C6FD039F16 /* RulePackCatalogComposition.swift */; }; 22B534F23EF9F05AE27A6D71 /* FeatureGuide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82D568056D9E6FACAC42D563 /* FeatureGuide.swift */; }; 239189796A5B358CF23BFCF9 /* MockRulesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8406C2280A8FF08E883A1E0 /* MockRulesView.swift */; }; + 2394FFF1E44E14D49C179431 /* AgentInventoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C66BAB050ED19B968765F3A9 /* AgentInventoryTests.swift */; }; 23CC0B3EE557E6F247EC4100 /* RuleEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08643257F8D11784563BD6CC /* RuleEditor.swift */; }; 24582219230DE364D6B2E2D1 /* BlockingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F529FE0E06B11298D8CD1E90 /* BlockingTests.swift */; }; 2497E16386CDFE769EFB0EF6 /* RulePackEditorDraft.swift in Sources */ = {isa = PBXBuildFile; fileRef = 089476A21B8A6AE7F3F4A53A /* RulePackEditorDraft.swift */; }; @@ -128,10 +130,12 @@ 413D6E44997AA769C597FF23 /* RulePackCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59B40E5AD74F74A029AD46E4 /* RulePackCatalogTests.swift */; }; 41750EE3E0461E85FC71FBAF /* VersionNameRepairTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F05A2DD30C79643247AF74C9 /* VersionNameRepairTests.swift */; }; 41EEA62E4E2A0DDC2FA6C7AC /* ExportVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE2A4754CFE5FD187CAF66AD /* ExportVisibilityTests.swift */; }; + 42BF947D7A3318099C106809 /* AlertTriageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F610C66E988211E9A65CB77 /* AlertTriageTests.swift */; }; 42E39E8ADC51DA39DE0784AD /* Motion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 992F80EF7B17CA25C7D83AC8 /* Motion.swift */; }; 42F74C2DB00410D8FB790B70 /* BuiltInRulePacks.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABD4C8B6E3620F5E46C69434 /* BuiltInRulePacks.swift */; }; 4323C68638C74F3E8D02FA65 /* PluginPackageVerifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C96560CB05BDDBB136E34B /* PluginPackageVerifier.swift */; }; 43329736A36E1C4367881E40 /* LocalRiskSettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 662F0B94BED1CA47AD516AC9 /* LocalRiskSettingsSection.swift */; }; + 439EC38B0B2E45EF0C8F626E /* ProviderProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81B846AC635F86CD636B6935 /* ProviderProfileStore.swift */; }; 4453A6861DA4D5A8AE3CC793 /* AskChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14C234AF77E7451A1505DDF9 /* AskChart.swift */; }; 451AB2522DC0E73CF1301EE1 /* FlowlightApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3244269609CC161996866B5 /* FlowlightApp.swift */; }; 45C6C2569D63744568474B91 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = F186517132592B36ED2B9F80 /* main.swift */; }; @@ -152,6 +156,7 @@ 4C028FF54CB024B6DAEA2DBA /* HTTPHostParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 228B5C4135EAD9D85F91ED0E /* HTTPHostParser.swift */; }; 4C4410D4E752D72D1934FDE9 /* AITrafficGraph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5F3C5C8A0175A409580782 /* AITrafficGraph.swift */; }; 4D676BCFC2B27CB77015BDFA /* FocusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61080233ED162067497EE2E3 /* FocusView.swift */; }; + 4DEE6972B16DC40CB8828B75 /* ProviderProfileStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81B846AC635F86CD636B6935 /* ProviderProfileStore.swift */; }; 4EF5727ADC37F80E20EF4327 /* TrafficMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC0A436E879000F8F5FF9E7 /* TrafficMonitor.swift */; }; 4F732CB0B9C2FB627217CC17 /* RulePackCatalogUpdaterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8507D8801643823A18134BF8 /* RulePackCatalogUpdaterTests.swift */; }; 4F929BE1C94FDF359E1B0F39 /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8ACA1ABA6B37B6457004C94A /* NetworkExtension.framework */; }; @@ -314,6 +319,7 @@ A8869D4F16D83EC7120D901E /* PluginStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55A095537DB24454B007D1B /* PluginStore.swift */; }; A912218AF0A00A7FB4EE5221 /* InsightsPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FA151AA64480DAACC602123 /* InsightsPanel.swift */; }; A9675AD6413CA1740A75CE8F /* JSONValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9926D17DD57B7F97E895C9 /* JSONValue.swift */; }; + AB9B40E51DA0C095CC143DC2 /* AgentInventoryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E1FBDB4AE42E05E5BE2131 /* AgentInventoryModel.swift */; }; AC5E1A01FEBC6A4C9951353E /* RuleSimulation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8BAA4EB36D9B7D7BF56AD1 /* RuleSimulation.swift */; }; ACC7529D79C19667761AEAD2 /* RulePackEventDeliveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB0C0840C443F8AEDD254AE0 /* RulePackEventDeliveryTests.swift */; }; AD57CBF846824C0BE9878BF1 /* LLMFacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95765A2F0D026D17736C9D9D /* LLMFacts.swift */; }; @@ -410,6 +416,7 @@ D6DB988F5D1D2A3C2A5753E5 /* IPCProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A2DDEBC714CDCFDB919F35 /* IPCProtocol.swift */; }; D73CBA15CD6E91DE465C0556 /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFF0C2E06D541DAEC1BC5F33 /* DesignTokens.swift */; }; D77B6BB95661074A90B12EE7 /* NettopParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 497DF4817317CE033F2A83A2 /* NettopParser.swift */; }; + D971EAE497302BE7B2F58AD7 /* AgentInventoryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E1FBDB4AE42E05E5BE2131 /* AgentInventoryModel.swift */; }; D9987CC20EB70831208A0430 /* OnDeviceAsk.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEDBAB278885D747B420AD3 /* OnDeviceAsk.swift */; }; DB09CEC00A661AF26C37244E /* DestinationProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = A62A00EA0C0D8B7844E6F7B2 /* DestinationProfile.swift */; }; DB969099AF17B4E0DBA3034C /* UpdateInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121FF2EBC93394E8EDCFF0BF /* UpdateInstaller.swift */; }; @@ -477,6 +484,7 @@ F26718FE423FEE15B5502B8A /* Rule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92BAF46D43D2171398478FC3 /* Rule.swift */; }; F454E718DC7E6F7BDA3ECA61 /* RuleSimulation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8BAA4EB36D9B7D7BF56AD1 /* RuleSimulation.swift */; }; F47E13AFAFF872BB15EA4E59 /* AskView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41AE1A6CC673544FCEC9E7F4 /* AskView.swift */; }; + F53DBAF8096CD2EECE7601BC /* ProviderProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF2F2DBF19E1D3F5BC89EBFE /* ProviderProfileTests.swift */; }; F5AE32D382E6DDD1B06783B8 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4849A413C60A9ABC4F9C16E /* SettingsView.swift */; }; F5F925E03C86F8B023DC788D /* ExtensionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53217054F0EFD8EC322BC5B1 /* ExtensionManager.swift */; }; F75EB274ED11E8E4225746B2 /* ReportsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCD98C0A49FC6C5521129087 /* ReportsView.swift */; }; @@ -587,6 +595,7 @@ 0ADADFFC230017F80D707ECE /* ResponseTransformTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponseTransformTests.swift; sourceTree = ""; }; 0CBBF571DC526F85E7232F79 /* FlowlightTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FlowlightTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0F2F1F8536857610FB4C9854 /* ProcessResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessResolver.swift; sourceTree = ""; }; + 0F610C66E988211E9A65CB77 /* AlertTriageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertTriageTests.swift; sourceTree = ""; }; 10036A17CDFCA45E068708EE /* RulePack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePack.swift; sourceTree = ""; }; 10E3B19A61C74DA87F74E4CA /* RulePackSimulation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePackSimulation.swift; sourceTree = ""; }; 11A65C830B699BC4477CA879 /* Guardrail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Guardrail.swift; sourceTree = ""; }; @@ -709,6 +718,7 @@ 80BC5E16F824FC642C12C8B0 /* BlockRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockRules.swift; sourceTree = ""; }; 810530D0FFDE61BE5CDB4CC2 /* BoundedHTTPDataTransportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BoundedHTTPDataTransportTests.swift; sourceTree = ""; }; 81434EE3C45929E8A768D42E /* AgentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentsView.swift; sourceTree = ""; }; + 81B846AC635F86CD636B6935 /* ProviderProfileStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderProfileStore.swift; sourceTree = ""; }; 81F190466C9691ADAF7BC8DA /* SystemExtensions.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemExtensions.framework; path = System/Library/Frameworks/SystemExtensions.framework; sourceTree = SDKROOT; }; 82D568056D9E6FACAC42D563 /* FeatureGuide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureGuide.swift; sourceTree = ""; }; 8507D8801643823A18134BF8 /* RulePackCatalogUpdaterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePackCatalogUpdaterTests.swift; sourceTree = ""; }; @@ -789,6 +799,9 @@ C35EF76A8728F308E2D6973A /* RulePackCatalogVerifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePackCatalogVerifier.swift; sourceTree = ""; }; C3E4E7F1E8B83D086AF0CB1B /* InspectNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectNavigationTests.swift; sourceTree = ""; }; C4A370EA2BA551D5351835E6 /* TrafficSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrafficSource.swift; sourceTree = ""; }; + C4E1FBDB4AE42E05E5BE2131 /* AgentInventoryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentInventoryModel.swift; sourceTree = ""; }; + C5E1A246D41B7421BB0C2FC8 /* PerformanceBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceBudgetTests.swift; sourceTree = ""; }; + C66BAB050ED19B968765F3A9 /* AgentInventoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentInventoryTests.swift; sourceTree = ""; }; C8251DBDC21C44CB92CC1C67 /* SettingsEnforcer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsEnforcer.swift; sourceTree = ""; }; CAC8A1658834152DBFBA4494 /* MockGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockGate.swift; sourceTree = ""; }; CAFF4F331D37F34C6C131ED5 /* TrafficGraphTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrafficGraphTests.swift; sourceTree = ""; }; @@ -827,6 +840,7 @@ EC292A1F1020AAA580DA3CDB /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; EC63F61F2956394B88C762F4 /* ExtensionRebootTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionRebootTests.swift; sourceTree = ""; }; ED54978358DD9DE1E792A8B9 /* VersionNamingRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionNamingRegressionTests.swift; sourceTree = ""; }; + EF2F2DBF19E1D3F5BC89EBFE /* ProviderProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderProfileTests.swift; sourceTree = ""; }; EFF0C2E06D541DAEC1BC5F33 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = ""; }; F05A2DD30C79643247AF74C9 /* VersionNameRepairTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionNameRepairTests.swift; sourceTree = ""; }; F062E6D35F98D8EEF1EBE47C /* PacketSniffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketSniffer.swift; sourceTree = ""; }; @@ -1058,10 +1072,12 @@ 47D0F2D7B8D7DEDEA845284F /* FlowlightTests */ = { isa = PBXGroup; children = ( + C66BAB050ED19B968765F3A9 /* AgentInventoryTests.swift */, DD30777A046F705110A99269 /* AgentLauncherTests.swift */, 3B07796D1108F037FF196443 /* AgentPolicyTests.swift */, DC8F91DE12A1C58BB2BD7038 /* AgentTests.swift */, B484EC45F6D3CF2D66D12BE0 /* AgentWorkspaceTests.swift */, + 0F610C66E988211E9A65CB77 /* AlertTriageTests.swift */, 15380DABDCB2492A132A90E4 /* AnswerTextTests.swift */, CF28074238079D33EFB6B862 /* AppleSystemAppTests.swift */, 4D73C529EED550F2BD81CB72 /* AppLifecycleTests.swift */, @@ -1099,7 +1115,9 @@ 2E35905A6578A1204796962E /* MockResponseTests.swift */, ADD9D2D024F76347C8414BD5 /* NetworkChannelTests.swift */, 15AF59D58B332F548630D949 /* ParserTests.swift */, + C5E1A246D41B7421BB0C2FC8 /* PerformanceBudgetTests.swift */, 22D460484C8D6DFA5D495132 /* PluginTests.swift */, + EF2F2DBF19E1D3F5BC89EBFE /* ProviderProfileTests.swift */, 45076E14820DDD4CFF352226 /* ProxyJanitorTests.swift */, 13D29667127E5DE57908D936 /* ProxyRelaunchTests.swift */, 0ADADFFC230017F80D707ECE /* ResponseTransformTests.swift */, @@ -1136,6 +1154,7 @@ F3C96560CB05BDDBB136E34B /* PluginPackageVerifier.swift */, E593C4ED11E1BCADD22099E6 /* PluginScriptRunner.swift */, A55A095537DB24454B007D1B /* PluginStore.swift */, + 81B846AC635F86CD636B6935 /* ProviderProfileStore.swift */, ); path = Plugins; sourceTree = ""; @@ -1267,6 +1286,7 @@ AFB91EF695297110D08D29F5 /* UI */ = { isa = PBXGroup; children = ( + C4E1FBDB4AE42E05E5BE2131 /* AgentInventoryModel.swift */, 082111849A66652DD9F1BE01 /* AgentsModel.swift */, 81434EE3C45929E8A768D42E /* AgentsView.swift */, EB04B24A750D5362449F8197 /* AlertsView.swift */, @@ -1653,10 +1673,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2394FFF1E44E14D49C179431 /* AgentInventoryTests.swift in Sources */, FF3F2EE3FE29F6827D527B31 /* AgentLauncherTests.swift in Sources */, 489DB4725FD189472CC078BE /* AgentPolicyTests.swift in Sources */, 2499CE8E13B7B57DDA281E39 /* AgentTests.swift in Sources */, EA2D23AFA530F762A6901E08 /* AgentWorkspaceTests.swift in Sources */, + 42BF947D7A3318099C106809 /* AlertTriageTests.swift in Sources */, FACBCE71B0804DB4187D132B /* AnswerTextTests.swift in Sources */, E8F73E4CF1E8701968CB2E96 /* AppLifecycleTests.swift in Sources */, B766E7284317D94D160C1386 /* AppleSystemAppTests.swift in Sources */, @@ -1694,7 +1716,9 @@ 8BD67221A510AAB93D73CC7D /* MockResponseTests.swift in Sources */, 5A3B1EB5936B67AD426A5967 /* NetworkChannelTests.swift in Sources */, 657B4D3A0DC9860BCAA78E25 /* ParserTests.swift in Sources */, + 21B9E226D66289FBC7175C11 /* PerformanceBudgetTests.swift in Sources */, E3F2CFD47F40B6A9481C5777 /* PluginTests.swift in Sources */, + F53DBAF8096CD2EECE7601BC /* ProviderProfileTests.swift in Sources */, ADC857099AB3E324DEC334BC /* ProxyJanitorTests.swift in Sources */, ED1CF3BBC1C4D317658D07A0 /* ProxyRelaunchTests.swift in Sources */, 697ACED9A5F7CF5854461210 /* ResponseTransformTests.swift in Sources */, @@ -1730,6 +1754,7 @@ B1E5CA21DA13E924DD50CB51 /* AgentAttributor.swift in Sources */, 2D10E8EE0B452C5746D2AF73 /* AgentCatalog.swift in Sources */, 5AD8431BC3E97C3DE167CF9F /* AgentGateway.swift in Sources */, + AB9B40E51DA0C095CC143DC2 /* AgentInventoryModel.swift in Sources */, 07B78C868D02BC6FB6FC561C /* AgentLauncher.swift in Sources */, 9162E455E37916AAD651349B /* AgentPolicy.swift in Sources */, 585CA811DBF12784CBF086EA /* AgentWorkspace.swift in Sources */, @@ -1844,6 +1869,7 @@ 155D31587F84936506FDEBB2 /* ProcessNaming.swift in Sources */, 61648ED4A0DBCEC775673448 /* ProtocolCatalog.swift in Sources */, 0D37699776684FD04F8D9D07 /* ProtocolClassifier.swift in Sources */, + 4DEE6972B16DC40CB8828B75 /* ProviderProfileStore.swift in Sources */, 2D89FAAC905437BEC596808B /* ProxyJanitor.swift in Sources */, B4C83BAE6447273B72D237AB /* ProxyRelaunch.swift in Sources */, 3E591F9FC749BBE46FE5C100 /* RemoteAsk.swift in Sources */, @@ -1945,6 +1971,7 @@ C35F10A4B87D29525A740DD5 /* AgentAttributor.swift in Sources */, DDE2B0B0A807C428B9F78120 /* AgentCatalog.swift in Sources */, 0E804FB918AC012315F3A903 /* AgentGateway.swift in Sources */, + D971EAE497302BE7B2F58AD7 /* AgentInventoryModel.swift in Sources */, 63CBA5AACC00FC44C982ADAF /* AgentLauncher.swift in Sources */, 9767A4CE180F4083B693CC19 /* AgentPolicy.swift in Sources */, 120C7E644032F70B351DB87E /* AgentWorkspace.swift in Sources */, @@ -2059,6 +2086,7 @@ 850653E102AF3CD2C3833524 /* ProcessNaming.swift in Sources */, CAE2EEC350E48F001B343DE1 /* ProtocolCatalog.swift in Sources */, 0ACF3321E6670591EC9B1306 /* ProtocolClassifier.swift in Sources */, + 439EC38B0B2E45EF0C8F626E /* ProviderProfileStore.swift in Sources */, AF1BB39DC13C09CAC6253943 /* ProxyJanitor.swift in Sources */, 3654D301BB54F59D172A1668 /* ProxyRelaunch.swift in Sources */, 6BE9B797667820AD0E72D789 /* RemoteAsk.swift in Sources */, @@ -2252,7 +2280,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 81; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -2267,7 +2295,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.17.3; + MARKETING_VERSION = 0.17.7; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2313,7 +2341,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 81; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 38RJUJHKZS; ENABLE_HARDENED_RUNTIME = YES; @@ -2328,7 +2356,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.17.3; + MARKETING_VERSION = 0.17.7; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -2393,7 +2421,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 77; + CURRENT_PROJECT_VERSION = 81; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -2414,7 +2442,7 @@ GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 0.17.3; + MARKETING_VERSION = 0.17.7; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; diff --git a/Flowlight/Analysis/AnomalyEngine.swift b/Flowlight/Analysis/AnomalyEngine.swift index 4205876e..4cb53a5b 100644 --- a/Flowlight/Analysis/AnomalyEngine.swift +++ b/Flowlight/Analysis/AnomalyEngine.swift @@ -231,7 +231,8 @@ final class AnomalyEngine: @unchecked Sendable { agentMinutes[agentKey] = Array(history.suffix(60)) // Allowlist: anything the agent (or its tools) contacts that isn't listed. - if let policy = policies[agentKey], policy.enabled, c.total > 0, + if let policy = AgentPolicy.resolve(policies.values, identities: [agentKey, agent]), + policy.enabled, c.total > 0, !policy.allows(host: k.domain, ip: k.remoteIP, isAIProvider: provider != nil) { let target = k.domain.isEmpty ? k.remoteIP : Self.registrableDomain(k.domain) if shouldAlert("allow|\(agentKey)|\(target)", every: 6 * 3600) { diff --git a/Flowlight/Analysis/GuardrailStore.swift b/Flowlight/Analysis/GuardrailStore.swift index 254aeab9..d42bcebd 100644 --- a/Flowlight/Analysis/GuardrailStore.swift +++ b/Flowlight/Analysis/GuardrailStore.swift @@ -63,7 +63,7 @@ final class GuardrailStore: ObservableObject { } func guardrails(for agent: String) -> [Guardrail] { - guardrails.filter { $0.agent.isEmpty || $0.agent.caseInsensitiveCompare(agent) == .orderedSame } + guardrails.filter { $0.agent.isEmpty || AgentCatalog.sameAgentIdentity($0.agent, agent) } } /// Counted from what the guardrails themselves record, so the tab can say whether any of this is doing diff --git a/Flowlight/App/AppNavigation.swift b/Flowlight/App/AppNavigation.swift index fc5e1617..80bfae34 100644 --- a/Flowlight/App/AppNavigation.swift +++ b/Flowlight/App/AppNavigation.swift @@ -24,7 +24,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .reports: return "worth-a-look" case .visualize: return "visualize" case .alerts: return "alerts" - case .rules: return "rules" + case .rules: return "policies" case .ask: return "ask" case .inspect: return "inspection" case .sources: return "sources" @@ -40,7 +40,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .reports: return "History" case .visualize: return "Connections" case .alerts: return "Alerts" - case .rules: return "Rules" + case .rules: return "Policies" case .ask: return "Ask" case .inspect: return "Inspect" case .sources: return "Sources" @@ -54,7 +54,7 @@ enum SidebarItem: String, CaseIterable, Identifiable { case .reports: return L("History") case .visualize: return L("Connections") case .alerts: return L("Alerts") - case .rules: return L("Rules") + case .rules: return L("Policies") case .ask: return L("Ask") case .inspect: return L("Inspect") case .sources: return L("Sources") @@ -143,8 +143,8 @@ enum SidebarSection: String, CaseIterable, Identifiable { } } -/// The three operational views behind the single Sources destination. Keeping this in shared navigation lets a -/// status notice open the exact recovery surface without putting three configuration screens back in the sidebar. +/// The three sections in the single Sources destination. The cases remain navigation state so restored windows, +/// screenshot arguments and links can scroll to the exact operational section without hiding the other two. enum SourcesTab: String, CaseIterable, Identifiable, Sendable { case collector, devices, coverage @@ -157,6 +157,19 @@ enum SourcesTab: String, CaseIterable, Identifiable, Sendable { case .coverage: return L("Coverage") } } + + var anchorID: String { "sources-\(rawValue)" } + + /// `devices` and `coverage` were once standalone screens, then visible tabs. Both old forms still restore to + /// the corresponding section; an explicit modern section always wins over the legacy screen value. + static func restored(explicitRawValue: String?, legacyScreenRawValue: String?) -> SourcesTab { + if let explicitRawValue, let explicit = SourcesTab(rawValue: explicitRawValue) { return explicit } + switch legacyScreenRawValue { + case "devices": return .devices + case "coverage": return .coverage + default: return .collector + } + } } /// Alert review and its detection tuning share a destination, but app-wide alert links must still be able to select @@ -168,20 +181,57 @@ enum AlertsTab: String, CaseIterable, Identifiable { @MainActor var title: String { self == .alerts ? L("Alerts") : L("Detection") } } -/// The three views inside Rules. This lives with the shared navigation state so an app-wide notice can take someone -/// straight to a changed pack, while ordinary rule links always land back on the rule list. +/// The two views inside Policies. The raw `rules` value is deliberately retained: restored windows and preferences +/// written before the product rename must keep landing on the inventory rather than silently resetting. enum RulesTab: String, CaseIterable, Identifiable { - case rules, packs, activity + case rules, activity var id: String { rawValue } @MainActor var title: String { switch self { - case .rules: return L("Rules") - case .packs: return L("Packs") - case .activity: return L("What they did") + case .rules: return L("Policies") + case .activity: return L("Activity") } } + + /// `packs` was a visible tab through 0.17.3. It now restores to the unified inventory, where managed packs have + /// their own section. Unknown future values take the same conservative path. + static func restored(from rawValue: String?) -> RulesTab { + rawValue == RulesTab.activity.rawValue ? .activity : .rules + } + + static func restoresManagedPackFocus(from rawValue: String?) -> Bool { + rawValue == "packs" + } +} + +enum AgentPolicySection: Equatable, Sendable { case allowlist, guardrails } + +struct AgentPolicyRequest: Equatable, Sendable { + var agentID: String + var section: AgentPolicySection + var guardrailID: UUID? = nil + var id = UUID() +} + +enum InspectPolicySection: Equatable, Sendable { case mocks, requestRewrites, responseTransforms } + +/// A precise request into the policy inventory. It is intentionally source-shaped rather than carrying an editor: +/// each destination still owns its consent, validation, and save behavior. +enum PolicyDestination: Equatable, Sendable { + case rule(UUID) + case allowlist(String) + case managedPack(String?) + case guardrail(id: UUID, agentID: String) + case mock(UUID) + case requestRewrite(UUID) + case responseTransform(UUID) +} + +struct PolicyRequest: Equatable, Sendable { + var destination: PolicyDestination + var id = UUID() } /// A rule the user started writing somewhere else — a table row, an alert — waiting for the Rules screen to open @@ -215,22 +265,23 @@ final class AppNavigation: ObservableObject { }() @Published var sourcesTab: SourcesTab = { let defaults = UserDefaults.standard - if let explicit = SourcesTab(rawValue: defaults.string(forKey: "FLSourcesTab") ?? "") { - return explicit - } - switch defaults.string(forKey: "FLScreen") { - case "devices": return .devices - case "coverage": return .coverage - default: return .collector - } + return SourcesTab.restored(explicitRawValue: defaults.string(forKey: "FLSourcesTab"), + legacyScreenRawValue: defaults.string(forKey: "FLScreen")) }() + /// A separate pulse makes a repeated link to the already-selected section scroll again. + @Published private(set) var sourcesRequestID = UUID() @Published var alertsTab: AlertsTab = .alerts @Published var rulesTab: RulesTab = { - RulesTab(rawValue: UserDefaults.standard.string(forKey: "FLRulesTab") ?? "") ?? .rules + RulesTab.restored(from: UserDefaults.standard.string(forKey: "FLRulesTab")) }() @Published var reportRequest: ReportRequest? @Published var inspectRequest: InspectRequest? @Published var ruleRequest: RuleRequest? + @Published var policyRequest: PolicyRequest? = { + RulesTab.restoresManagedPackFocus(from: UserDefaults.standard.string(forKey: "FLRulesTab")) + ? PolicyRequest(destination: .managedPack(nil)) : nil + }() + @Published var agentPolicyRequest: AgentPolicyRequest? private struct TrailEntry { var item: SidebarItem @@ -289,12 +340,34 @@ final class AppNavigation: ObservableObject { func showSources(_ tab: SourcesTab = .collector) { sourcesTab = tab + sourcesRequestID = UUID() show(.sources) } func showPackReviews() { show(.rules) - rulesTab = .packs + policyRequest = PolicyRequest(destination: .managedPack(nil)) + } + + func showPolicyPack(_ packID: String) { + show(.rules) + policyRequest = PolicyRequest(destination: .managedPack(packID)) + } + + func showPolicy(_ destination: PolicyDestination) { + show(.rules) + policyRequest = PolicyRequest(destination: destination) + } + + func showAgentPolicy(agentID: String, section: AgentPolicySection, guardrailID: UUID? = nil) { + agentPolicyRequest = AgentPolicyRequest(agentID: agentID, section: section, guardrailID: guardrailID) + followLink(to: .agents) + } + + func showInspectionPolicy(_ section: InspectPolicySection, policyID: UUID? = nil) { + guard SidebarItem.availableCases.contains(.inspect) else { return } + inspectRequest = InspectRequest(search: "", policySection: section, policyID: policyID) + followLink(to: .inspect) } func showReport(filter: TrafficFilter, granularity: Granularity? = nil, @@ -378,5 +451,9 @@ struct InspectRequest: Equatable { var mcpServer: String? = nil /// Explicitly displayed and removable in Inspect; Reports uses it for the potential-harm drill-in. var potentialHarmOnly = false + /// Opens setup on the contextual editor that owns this kind of policy. + var policySection: InspectPolicySection? = nil + /// The exact retained policy to edit. Nil keeps section-level navigation for generic setup links. + var policyID: UUID? = nil var id = UUID() } diff --git a/Flowlight/App/FlowlightApp.swift b/Flowlight/App/FlowlightApp.swift index dee23d8a..73d9706e 100644 --- a/Flowlight/App/FlowlightApp.swift +++ b/Flowlight/App/FlowlightApp.swift @@ -194,14 +194,14 @@ struct FlowlightApp: App { #endif MenuBarExtra { - MenuBarContent() + MenuBarContent(liveState: monitor.liveState) .environmentObject(monitor) .environmentObject(extensionManager) .environmentObject(nav) .environmentObject(updater) .environmentObject(focus) } label: { - MenuBarLabel() + MenuBarLabel(liveState: monitor.liveState) .environmentObject(monitor) .environmentObject(extensionManager) .environmentObject(updater) @@ -215,6 +215,7 @@ struct FlowlightApp: App { } struct MenuBarLabel: View { + @ObservedObject var liveState: TrafficLiveState @EnvironmentObject var monitor: TrafficMonitor @EnvironmentObject var extensionManager: ExtensionManager @EnvironmentObject var updater: UpdateChecker @@ -242,7 +243,7 @@ struct MenuBarLabel: View { .accessibilityHidden(true) } if showRates { - Text("↓\(ByteFormat.compact(monitor.currentIn)) ↑\(ByteFormat.compact(monitor.currentOut))") + Text("↓\(ByteFormat.compact(liveState.currentIn)) ↑\(ByteFormat.compact(liveState.currentOut))") .monospacedDigit() } else { Image(systemName: monitor.unacknowledgedAlerts > 0 ? "exclamationmark.arrow.triangle.2.circlepath" : "arrow.up.arrow.down.circle") diff --git a/Flowlight/App/TrafficMonitor.swift b/Flowlight/App/TrafficMonitor.swift index 2bd069b3..4e8d7339 100644 --- a/Flowlight/App/TrafficMonitor.swift +++ b/Flowlight/App/TrafficMonitor.swift @@ -17,27 +17,51 @@ struct LiveTalker: Identifiable, Equatable { /// Everything the one-second timer publishes to the Live screen. Replacing one snapshot gives SwiftUI one coherent /// update; publishing the chart, table, rates, and source state separately can reload NSTableView while its delegate /// is still reconciling the previous publication. -private struct LiveSnapshot: Equatable { +struct LiveSnapshot: Equatable { var talkers: [LiveTalker] = [] var series: [SeriesPoint] = [] var currentIn: Double = 0 var currentOut: Double = 0 + var sessionIn: Int64 = 0 + var sessionOut: Int64 = 0 var isReceiving = false } +/// Isolates the one-second live feed from the slower application state. Views that show rates or the live chart +/// observe this object directly; publishing a new sample must not invalidate every screen that uses TrafficMonitor. +@MainActor +final class TrafficLiveState: ObservableObject { + @Published private(set) var snapshot = LiveSnapshot() + + var talkers: [LiveTalker] { snapshot.talkers } + var series: [SeriesPoint] { snapshot.series } + var currentIn: Double { snapshot.currentIn } + var currentOut: Double { snapshot.currentOut } + var sessionIn: Int64 { snapshot.sessionIn } + var sessionOut: Int64 { snapshot.sessionOut } + var isReceiving: Bool { snapshot.isReceiving } + + func publish(_ next: LiveSnapshot) { + if next != snapshot { snapshot = next } + } +} + /// Owns the capture source, database, anomaly engine and live state. @MainActor final class TrafficMonitor: ObservableObject { @Published private(set) var status = L("Starting…") @Published private(set) var mode: CaptureMode - @Published private var live = LiveSnapshot() - var talkers: [LiveTalker] { live.talkers } - var liveSeries: [SeriesPoint] { live.series } - var currentIn: Double { live.currentIn } - var currentOut: Double { live.currentOut } - var isReceiving: Bool { live.isReceiving } - @Published private(set) var sessionIn: Int64 = 0 - @Published private(set) var sessionOut: Int64 = 0 + let liveState = TrafficLiveState() + var talkers: [LiveTalker] { liveState.talkers } + var liveSeries: [SeriesPoint] { liveState.series } + var currentIn: Double { liveState.currentIn } + var currentOut: Double { liveState.currentOut } + var sessionIn: Int64 { liveState.sessionIn } + var sessionOut: Int64 { liveState.sessionOut } + var isReceiving: Bool { liveState.isReceiving } + private var sessionInTotal: Int64 = 0 + private var sessionOutTotal: Int64 = 0 + /// Sidebar badge count. The compatibility name is retained, but this is explicitly the number in `new` state. @Published private(set) var unacknowledgedAlerts = 0 @Published private(set) var captureState: PacketSniffer.State = .stopped @Published private(set) var hostnamesLearned = 0 @@ -80,6 +104,8 @@ final class TrafficMonitor: ObservableObject { let guardrails = GuardrailStore() /// Advisory inspection plugins: official built-ins first, installed third-party packs later. let plugins = PluginStore() + /// Local non-secret provider metadata shared by Settings, live plugin execution, and backfill. + let providerProfiles = ProviderProfileStore() /// Channels that aren't the network: Bluetooth, USB. Both off until switched on. let devices = DeviceStore() /// Questions in plain language, answered from the recorded history. @@ -120,10 +146,10 @@ final class TrafficMonitor: ObservableObject { func windowVisibility(_ window: AnyObject, isVisible: Bool) { guard windows.update(window, isVisible: isVisible) == .becameHidden else { return } // Nothing can see them, and they are rebuilt on the next tick once something can. - var next = live + var next = liveState.snapshot next.series = [] next.talkers = [] - if next != live { live = next } + liveState.publish(next) } private let liveWindow = 120 @@ -168,6 +194,9 @@ final class TrafficMonitor: ObservableObject { } readDB = (try? TrafficDatabase(url: db.url, readOnly: true)) ?? db engine = AnomalyEngine(db: db, activity: activity) + providerProfiles.installRuntimeSink { [weak db] profiles in + db?.async { $0.replaceProviderProfiles(profiles) } + } proxyRulePackEvents = RulePackEventDelivery { [weak rulePacks] batch, claim, completion in Task { @MainActor in rulePacks?.record( @@ -290,6 +319,9 @@ final class TrafficMonitor: ObservableObject { self?.rules.record([event]) } } + inspection.onGuardrailHit = { [weak self] event in + Task { @MainActor in self?.guardrails.record([event]) } + } // Export reads ordinary traffic from the same minute rollups as Reports, but lineage metadata crosses an // external trust boundary and therefore comes from the provenance-aware projection. exporter.start(rollups: { [weak self] from, to in @@ -783,12 +815,14 @@ final class TrafficMonitor: ObservableObject { focus = scope perSecond.removeAll() sessionPerApp.removeAll() - sessionIn = 0 - sessionOut = 0 - var next = live + sessionInTotal = 0 + sessionOutTotal = 0 + var next = liveState.snapshot next.talkers = [] next.series = [] - if next != live { live = next } + next.sessionIn = 0 + next.sessionOut = 0 + liveState.publish(next) refreshAlertCount() } @@ -816,8 +850,8 @@ final class TrafficMonitor: ObservableObject { } bucket[k.bundleID] = entry sessionPerApp[k.bundleID, default: FlowCounters()] += r.counters - sessionIn += r.counters.bytesIn - sessionOut += r.counters.bytesOut + sessionInTotal += r.counters.bytesIn + sessionOutTotal += r.counters.bytesOut } perSecond[batch.timestamp] = bucket } @@ -825,7 +859,9 @@ final class TrafficMonitor: ObservableObject { private func tickLive() { let now = Int64(Date().timeIntervalSince1970) - var next = live + var next = liveState.snapshot + next.sessionIn = sessionInTotal + next.sessionOut = sessionOutTotal next.isReceiving = lastDataAt.map { Date().timeIntervalSince($0) < 5 } ?? false // Without a window, only the last few seconds are needed for the menu bar rates. let keep = Int64(uiVisible ? liveWindow : rateWindow + 3) @@ -879,7 +915,7 @@ final class TrafficMonitor: ObservableObject { } private func publishLive(_ snapshot: LiveSnapshot) { - if snapshot != live { live = snapshot } + liveState.publish(snapshot) } // MARK: Maintenance @@ -909,7 +945,7 @@ final class TrafficMonitor: ObservableObject { func refreshAlertCount() { let scope = focus db.async { [weak self] db in - let count = try db.unacknowledgedAlertCount(focus: scope) + let count = try db.newAlertCount(focus: scope) Task { @MainActor in self?.unacknowledgedAlerts = count } } } @@ -952,8 +988,16 @@ final class TrafficMonitor: ObservableObject { try await db.asyncResult { try $0.clearAll() } self.sessionPerApp.removeAll() self.perSecond.removeAll() - self.sessionIn = 0 - self.sessionOut = 0 + self.sessionInTotal = 0 + self.sessionOutTotal = 0 + var next = self.liveState.snapshot + next.talkers = [] + next.series = [] + next.currentIn = 0 + next.currentOut = 0 + next.sessionIn = 0 + next.sessionOut = 0 + self.liveState.publish(next) self.rulePacks.finishActivityClear(succeeded: true) self.rules.clearPublishedEventsAfterDurableDeletion() self.devices.finishHistoryClear(succeeded: true) @@ -1039,7 +1083,8 @@ final class TrafficMonitor: ObservableObject { /// "Allow from now on", from the alert about a connection that was refused. func allowFromNowOn(pattern: String, agentID: String) { db.async { [engine, weak self] db in - var policy = try db.loadPolicies()[agentID] ?? AgentPolicy(agentID: agentID) + let loaded = try db.loadPolicies() + var policy = AgentPolicy.resolve(loaded.values, identities: [agentID]) ?? AgentPolicy(agentID: agentID) guard !policy.patterns.contains(pattern) else { return } policy.patterns.append(pattern) try db.savePolicy(policy) @@ -1051,13 +1096,49 @@ final class TrafficMonitor: ObservableObject { } } - func acknowledgeAlerts(ids: [Int64]?) { - db.async { [weak self] db in - try db.acknowledgeAlerts(ids: ids) - Task { @MainActor in - self?.refreshAlertCount() - self?.dataVersion += 1 - } + @discardableResult + func acknowledgeAlerts(ids: [Int64]?, focus: FocusScope = .none) async -> Bool { + await updateAlertTriage { database in + try database.acknowledgeAlerts(ids: ids, focus: focus) + } + } + + @discardableResult + func setAlertTriageState(_ state: AlertTriageState, ids: [Int64]) async -> Bool { + await updateAlertTriage { database in + try database.setAlertTriageState(state, ids: ids) + } + } + + @discardableResult + func setAlertDisposition(_ disposition: AlertDisposition, ids: [Int64]) async -> Bool { + await updateAlertTriage { database in + try database.setAlertDisposition(disposition, ids: ids) } } + + @discardableResult + func setAlertNote(_ note: String, id: Int64) async -> Bool { + await updateAlertTriage { database in + try database.setAlertNote(note, id: id) + } + } + + private func updateAlertTriage( + _ mutation: @escaping @Sendable (TrafficDatabase) throws -> Void + ) async -> Bool { + do { + try await db.asyncResult(mutation) + alertTriageDidChange() + return true + } catch { + lastError = L("Couldn't update alerts: %@", error.localizedDescription) + return false + } + } + + private func alertTriageDidChange() { + refreshAlertCount() + dataVersion += 1 + } } diff --git a/Flowlight/Ask/AskProvider.swift b/Flowlight/Ask/AskProvider.swift index bb1c97d5..fd8f209d 100644 --- a/Flowlight/Ask/AskProvider.swift +++ b/Flowlight/Ask/AskProvider.swift @@ -103,7 +103,7 @@ struct AskRecordedCall: Identifiable, Equatable, Sendable { var chart: AskChart? /// Which app screen clicking should open when Reports cannot represent this result. var screen: SidebarItem? - /// The exact operational tab when that destination is Sources. + /// The exact section to focus when that destination is Sources. var sourcesTab: SourcesTab? = nil } diff --git a/Flowlight/Ask/AskQueryRunner.swift b/Flowlight/Ask/AskQueryRunner.swift index d7b0a804..530ebc64 100644 --- a/Flowlight/Ask/AskQueryRunner.swift +++ b/Flowlight/Ask/AskQueryRunner.swift @@ -22,7 +22,7 @@ enum AskQueryRunner { var chart: AskChart? /// Where the answer points for a question about the app rather than about traffic. var screen: SidebarItem? - /// The exact operational tab when that screen is Sources. + /// The exact section to focus when that screen is Sources. var sourcesTab: SourcesTab? = nil } diff --git a/Flowlight/Ask/FeatureGuide.swift b/Flowlight/Ask/FeatureGuide.swift index c0d7a7ac..97f4b054 100644 --- a/Flowlight/Ask/FeatureGuide.swift +++ b/Flowlight/Ask/FeatureGuide.swift @@ -19,7 +19,7 @@ struct FeatureGuide: Equatable, Sendable, Identifiable { var steps: [String] /// Where it lives, so an answer can offer to go there. var screen: SidebarItem? - /// The operational tab behind Sources, when the destination needs more precision than one sidebar item. + /// The section to focus in Sources, when the destination needs more precision than one sidebar item. var sourcesTab: SourcesTab? = nil /// What it can't do. Left out of an answer, these are the things people discover the hard way. var caveats: [String] = [] @@ -47,7 +47,7 @@ extension FeatureGuide { FeatureGuide( id: "capture", title: "Capture source", summary: "Where traffic comes from. The Network Extension is a content filter that sits in the data path and attributes every flow to its app; the nettop sampler needs no entitlement but samples once a second and misses flows shorter than that.", - steps: ["Open Sources and choose Collector.", "Pick Network Extension or nettop sampler.", + steps: ["Open Sources and go to Collector.", "Pick Network Extension or nettop sampler.", "For the extension, press Install & enable filter and approve it in System Settings."], screen: .sources, sourcesTab: .collector, @@ -58,7 +58,7 @@ extension FeatureGuide { FeatureGuide( id: "hostnames", title: "Hostnames", summary: "Turning IP addresses into names. The extension reads TLS SNI and DNS answers itself. On the sampler, a one-time packet-capture setup is what lets Flowlight see those names.", - steps: ["Open Sources and choose Collector.", "Turn on Read hostnames from the network.", "Give the helper permission when macOS asks."], + steps: ["Open Sources and go to Collector.", "Turn on Read hostnames from the network.", "Give the helper permission when macOS asks."], screen: .sources, sourcesTab: .collector, caveats: ["Without it the sampler falls back to reverse DNS, which is often missing or wrong."], @@ -99,14 +99,14 @@ extension FeatureGuide { keywords: ["allowlist", "allow", "whitelist", "block agent", "enforce"]), FeatureGuide( - id: "rules", title: "Rules", - summary: "Block or allow anything: an app, an agent, a destination, a URL, or a pairing of those — forever, until a time, until Flowlight quits, or between chosen hours. The most specific rule wins and a tie goes to the block.", - steps: ["Open Rules and press +, or right-click any row in Live, History, AI Agents or Inspect and choose Block.", - "Name an app or a destination, pick block or allow, and choose how long it lasts.", - "Pause Blocking in the toolbar stands every rule down for a while."], + id: "rules", title: "Policies", + summary: "See ordinary rules, agent allowlists, managed packs and inspection policies together, with a truthful state and activity trail for each. Ordinary rules can block or allow an app, agent, destination, URL or pairing for a chosen duration.", + steps: ["Open Policies and press + to write an ordinary rule, or right-click a row in Live, History, AI Agents or Inspect and choose Block.", + "Select any existing policy to open the contextual editor that owns it.", + "Use Activity to review decisions, and Pause Blocking when you need a temporary escape hatch."], screen: .rules, - caveats: ["A rule that names a path can only be carried out by HTTPS inspection; one that names a host needs the Network Extension. A rule the current setup can't enforce says so in the list."], - keywords: ["block", "refuse", "deny", "firewall", "stop app", "allow once", "pause"]), + caveats: ["A URL rule needs HTTPS inspection; a host rule needs the Network Extension. Policies distinguishes enforcing, reporting, watching, waiting and unavailable controls rather than calling every enabled item active."], + keywords: ["policy", "rules", "allowlist", "managed pack", "block", "refuse", "deny", "firewall", "stop app", "allow once", "pause"]), FeatureGuide( id: "guardrails", title: "Agent guardrails", @@ -176,7 +176,7 @@ extension FeatureGuide { FeatureGuide( id: "devices", title: "Devices: camera, Bluetooth and USB", summary: "Whether each camera is active, which Bluetooth and USB devices are paired or attached, and when that changed. Every channel is off until you turn it on.", - steps: ["Open Sources and choose Devices.", "Turn on Watch camera activity, Watch Bluetooth, or Watch USB and external storage."], + steps: ["Open Sources and go to Devices.", "Turn on Watch camera activity, Watch Bluetooth, or Watch USB and external storage."], screen: .sources, sourcesTab: .devices, caveats: ["Camera activity names the camera, not the app using it; Flowlight never reads frames or requests camera access.", @@ -223,7 +223,7 @@ extension FeatureGuide { guard guide.id == "capture" else { return guide } var storeGuide = guide storeGuide.summary = "Where traffic comes from. The Mac App Store build uses Flowlight's content-filter extension to attribute every flow to its app." - storeGuide.steps = ["Open Sources and choose Collector.", "Under Content filter, press Enable Filter and approve it in System Settings."] + storeGuide.steps = ["Open Sources and go to Collector.", "Under Content filter, press Enable Filter and approve it in System Settings."] storeGuide.caveats = ["macOS runs one content filter at a time. If a VPN or a security agent holds that slot, Flowlight cannot receive traffic."] storeGuide.keywords = ["network extension", "content filter", "source", "no traffic", "nothing showing"] return storeGuide diff --git a/Flowlight/Inspection/InspectionController.swift b/Flowlight/Inspection/InspectionController.swift index d95dfaec..a2a27a61 100644 --- a/Flowlight/Inspection/InspectionController.swift +++ b/Flowlight/Inspection/InspectionController.swift @@ -148,6 +148,8 @@ final class InspectionController: ObservableObject { nonisolated(unsafe) var guardrails: @Sendable () -> [Guardrail] = { [] } /// Called with every tool a guardrail took away, and every call it refused. nonisolated(unsafe) var onGuardrail: @Sendable (RuleEvent) -> Void = { _ in } + /// Updates a guardrail's hit count when the exchange itself carries the durable activity record. + nonisolated(unsafe) var onGuardrailHit: @Sendable (RuleEvent) -> Void = { _ in } init() { UserDefaults.standard.register(defaults: [ @@ -204,9 +206,16 @@ final class InspectionController: ObservableObject { || Rule.appMatches($0.app, bundleID: owner.appName, agentKey: owner.agentName ?? "") } } + let mocks = MockRules.mocks(mockRules(), host: host).map { rule -> MockRule in + var recorded = rule + recorded.recordingReference = InspectionPolicyReference.encode([ + InspectionPolicyReference.Entry(id: rule.id, title: rule.title), + ]) ?? rule.title + return recorded + } return refusals.map { $0.asRefusal(host: host, ruleSession: RuleStore.session, ruleGeneration: generation) - } + MockRules.mocks(mockRules(), host: host) + } + mocks } proxy.mockRules = { host, clientPort in answersFor(host, clientPort) } proxy.authorizeConnection = { [weak self, recorder, proxy] host, port, clientPort, answer in @@ -236,30 +245,43 @@ final class InspectionController: ObservableObject { if guardsApply, let body = Self.body(of: bytes), let refusal = GuardrailEngine.refuse(jsonrpc: body, guardrails: guardrails, agent: agent, server: server) { self.report(refusal.guardrail, subject: refusal.subject, owner: owner, host: host, - port: UInt16(clamping: 443), method: head.method, engine: .request) + port: UInt16(clamping: 443), method: head.method, engine: .guardrail) return .answer(MockRule(id: refusal.guardrail.id, name: refusal.guardrail.title, host: host, path: "*", status: 200, body: refusal.body, blocked: true)) } var current = bytes - var notes: [String] = [] + var guardrailReference: String? + var rewriteReference: String? // Guardrails first — strip refused tools from the declaration — so a rewrite acts on the filtered body. if guardsApply, let body = Self.body(of: current), let filtered = GuardrailEngine.filter(request: body, guardrails: guardrails, agent: agent) { current = Self.reframe(current, body: filtered.body) - self.report(guardrails.first { g in filtered.removed.contains { g.refuses(agent: agent, server: server, tool: $0) } }, - subject: filtered.removed.joined(separator: ", "), owner: owner, host: host, - port: UInt16(clamping: 443), method: head.method, engine: .request) - notes.append(L("Removed %@", filtered.removed.joined(separator: ", "))) + let applied = guardrails.filter { guardrail in + filtered.removed.contains { guardrail.refuses(agent: agent, server: server, tool: $0) } + } + // A filtered request is durably represented by the exchange's stable guardrail references below. + // Refused calls still use RuleEvent because no upstream exchange is emitted for them; recording both + // sources here would make one filter application appear twice in Policies › Activity. Hit counters + // are updated independently so the Guardrails pane remains truthful without duplicating activity. + for guardrail in applied { + self.reportHit(guardrail, subject: filtered.removed.joined(separator: ", "), owner: owner, + host: host, port: UInt16(clamping: 443), method: head.method) + } + guardrailReference = InspectionPolicyReference.encode(applied.map { + InspectionPolicyReference.Entry(id: $0.id, title: $0.localizedTitle) + }) } // Then the user's rewrite rules — header and JSON-body edits. let path = head.target.split(separator: "?").first.map(String.init) ?? "/" if !rewrites.isEmpty, let edited = RewriteRules.apply(rewrites, to: current, host: host, method: head.method, path: path) { current = edited.data - notes.append(edited.note) + rewriteReference = InspectionPolicyReference.encode(edited.rules.map { + InspectionPolicyReference.Entry(id: $0.id, title: $0.title) + }) } - guard !notes.isEmpty else { return nil } - return .replace(current, note: notes.joined(separator: " · ")) + guard guardrailReference != nil || rewriteReference != nil else { return nil } + return .replace(current, guardrail: guardrailReference, rewrite: rewriteReference) } } proxy.responseInterventions = { flow in @@ -274,7 +296,10 @@ final class InspectionController: ObservableObject { guard let replacement = ResponseScriptRunner.shared.transform(rules: applicable, request: ResponseTransformRequest(head: context.head, host: flow.host, scheme: flow.scheme, port: flow.port), responseHead: responseHead, responseBody: responseBody) else { return nil } - return ResponseGate.Replacement(body: replacement.body, note: replacement.note) + let reference = InspectionPolicyReference.encode(replacement.rules.map { + InspectionPolicyReference.Entry(id: $0.id, title: $0.title) + }) ?? replacement.note + return ResponseGate.Replacement(body: replacement.body, note: reference) }, shouldTransform: { context in !ResponseTransformRules.applicable(rules, host: flow.host, method: context.head.method, path: context.head.target).isEmpty @@ -312,7 +337,10 @@ final class InspectionController: ObservableObject { // Plain-HTTP requests reach the recorder regardless of scope; keep only what the scope allows. let (scope, _) = scopeAndList() // A mocked exchange is always kept: an answer Flowlight invented has to be visible wherever it lands. - guard scope == .all || exchange.agent != nil || exchange.note != nil || exchange.mockRule != nil || exchange.responseTransform != nil else { return } + guard scope == .all || exchange.agent != nil || exchange.note != nil || exchange.mockRule != nil + || exchange.guardrail != nil || exchange.requestRewrite != nil || exchange.responseTransform != nil else { + return + } Task { @MainActor [weak self] in // Capture is committed first. The database transaction optionally creates a tiny derived candidate; // the wake below schedules later work and never waits for model inference on the recorder path. @@ -336,10 +364,23 @@ final class InspectionController: ObservableObject { nonisolated private func report(_ guardrail: Guardrail?, subject: String, owner: InspectionRecorder.Owner, host: String, port: UInt16, method: String, engine: Rule.Engine) { guard let guardrail else { return } - onGuardrail(RuleEvent(at: Int64(Date().timeIntervalSince1970), ruleID: guardrail.id, action: .block, - engine: engine, agentKey: owner.agent ?? owner.bundleID, - agentName: owner.agentName ?? owner.appName, appName: owner.appName, - bundleID: owner.bundleID, host: host, ip: "", port: port, path: subject, method: method)) + onGuardrail(guardrailEvent(guardrail, subject: subject, owner: owner, host: host, port: port, + method: method, engine: engine)) + } + + nonisolated private func reportHit(_ guardrail: Guardrail, subject: String, owner: InspectionRecorder.Owner, + host: String, port: UInt16, method: String) { + onGuardrailHit(guardrailEvent(guardrail, subject: subject, owner: owner, host: host, port: port, + method: method, engine: .guardrail)) + } + + nonisolated private func guardrailEvent(_ guardrail: Guardrail, subject: String, + owner: InspectionRecorder.Owner, host: String, port: UInt16, + method: String, engine: Rule.Engine) -> RuleEvent { + RuleEvent(at: Int64(Date().timeIntervalSince1970), ruleID: guardrail.id, action: .block, + engine: engine, agentKey: owner.agent ?? owner.bundleID, + agentName: owner.agentName ?? owner.appName, appName: owner.appName, + bundleID: owner.bundleID, host: host, ip: "", port: port, path: subject, method: method) } /// The body of a framed request, or nil when there isn't one to read. diff --git a/Flowlight/Inspection/InspectionProxy.swift b/Flowlight/Inspection/InspectionProxy.swift index b46e3d7e..18ab74ba 100644 --- a/Flowlight/Inspection/InspectionProxy.swift +++ b/Flowlight/Inspection/InspectionProxy.swift @@ -32,6 +32,9 @@ protocol ProxyObserver: AnyObject { /// The request whose bytes come next left with something taken out of it — a guardrail removing tools an /// agent is not allowed to use. What is recorded is what the server was sent, which is what happened. func flow(_ flow: ProxyFlow, guardedBy note: String) + /// A configured rewrite changed the request that left for the server. Kept separate from guardrail metadata so + /// Activity can say whether policy removed an agent capability or edited an HTTP request. + func flow(_ flow: ProxyFlow, rewrittenBy note: String) /// The upstream response bytes that come next were changed before the client received them. This is deliberately /// distinct from a mock (which never contacted the origin) and a guardrail (which changed the request). func flow(_ flow: ProxyFlow, responseTransformedBy note: String) @@ -412,15 +415,17 @@ final class InspectionProxy: @unchecked Sendable { case .hold(let bytes): // Recorded like any other request byte: what the agent sent is exactly what it would have sent. observer?.flow(flow, clientSent: bytes) - case .rewritten(let bytes, let note): - // Recorded as what left, not as what arrived: Inspect shows the request the server was sent. - observer?.flow(flow, guardedBy: note) + case .rewritten(let bytes, let guardrail, let rewrite): + // Recorded as what left, not as what arrived: Inspect shows the request the server was sent. Guardrail + // capability removal and configured request rewriting retain independent activity identities. + if let guardrail { observer?.flow(flow, guardedBy: guardrail) } + if let rewrite { observer?.flow(flow, rewrittenBy: rewrite) } observer?.flow(flow, clientSent: bytes) onward.append(bytes) case .answer(let rule, let bytes, let head): // Mark before the bytes that complete the request, so the recorder can label the exchange it is // about to parse rather than having to match it up afterwards. - observer?.flow(flow, mockedBy: rule.title) + observer?.flow(flow, mockedBy: rule.recordingReference ?? rule.title) observer?.flow(flow, clientSent: bytes) onAnswered(rule, flow, head) answer(rule, to: client, flow: flow) diff --git a/Flowlight/Inspection/InspectionRecorder.swift b/Flowlight/Inspection/InspectionRecorder.swift index 5067bd1c..10344df0 100644 --- a/Flowlight/Inspection/InspectionRecorder.swift +++ b/Flowlight/Inspection/InspectionRecorder.swift @@ -44,6 +44,9 @@ struct HTTPExchange: Identifiable, Equatable, Sendable { /// What a guardrail took out of this request before it left. Its own field for the same reason: the exchange /// is real and was really sent, but it is not quite what the agent wrote, and that has to be readable. var guardrail: String? + /// What a configured request rewrite changed before the request left. Earlier builds put this text in + /// `guardrail`; new recordings keep the two effects distinct without altering either enforcement path. + var requestRewrite: String? = nil /// The rule that changed a real upstream response before the client received it. A mock is intentionally not /// reused here: a transformed exchange really reached its origin. var responseTransform: String? = nil @@ -111,7 +114,15 @@ enum SocketOwner { /// them to the process (and agent) behind the connection, reads tool calls, redacts credentials, and hands each /// finished exchange to `onExchange`. final class InspectionRecorder: ProxyObserver, @unchecked Sendable { - private struct Pending { var head: HTTPHead; var body: HTTPBody; var started: Date; var mock: String?; var guardrail: String?; var responseTransform: String? } + private struct Pending { + var head: HTTPHead + var body: HTTPBody + var started: Date + var mock: String? + var guardrail: String? + var requestRewrite: String? + var responseTransform: String? + } private final class FlowState { let request: HTTPStreamParser @@ -120,6 +131,7 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable { /// Set by the proxy just before the bytes that complete a request it answers itself. var nextMock: String? var nextGuardrail: String? + var nextRequestRewrite: String? /// Marked by the proxy immediately before the transformed response is fed here. The oldest pending request /// is the response being delivered, including on a pipelined keep-alive connection. var nextResponseTransform: String? @@ -162,9 +174,11 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable { state.request.onHead = { response.requestMethods.append($0.method) } state.request.onMessage = { [weak state] head, body in state?.queue.append(Pending(head: head, body: body, started: Date(), mock: state?.nextMock, - guardrail: state?.nextGuardrail, responseTransform: nil)) + guardrail: state?.nextGuardrail, requestRewrite: state?.nextRequestRewrite, + responseTransform: nil)) state?.nextMock = nil state?.nextGuardrail = nil + state?.nextRequestRewrite = nil } state.response.onMessage = { [weak self, weak state] head, body in guard let self, let state, !state.queue.isEmpty else { return } @@ -195,6 +209,7 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable { func flow(_ flow: ProxyFlow, serverSent data: Data) { flows[flow.id]?.response.feed(data) } func flow(_ flow: ProxyFlow, mockedBy rule: String) { flows[flow.id]?.nextMock = rule } func flow(_ flow: ProxyFlow, guardedBy note: String) { flows[flow.id]?.nextGuardrail = note } + func flow(_ flow: ProxyFlow, rewrittenBy note: String) { flows[flow.id]?.nextRequestRewrite = note } func flow(_ flow: ProxyFlow, responseTransformedBy note: String) { flows[flow.id]?.nextResponseTransform = note } func flowEnded(_ flow: ProxyFlow, note: String?) { @@ -204,7 +219,8 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable { // Nothing was decrypted; record why, so the user sees which app and host were passed through. let head = HTTPHead(startLine: "CONNECT \(flow.host):\(flow.port) HTTP/1.1", headers: []) emit(flow: flow, state: state, - request: Pending(head: head, body: HTTPBody(), started: flow.started, mock: nil, guardrail: nil), + request: Pending(head: head, body: HTTPBody(), started: flow.started, mock: nil, guardrail: nil, + requestRewrite: nil, responseTransform: nil), responseHead: nil, responseBody: HTTPBody(), note: note) } } @@ -256,7 +272,8 @@ final class InspectionRecorder: ProxyObserver, @unchecked Sendable { contentType: request.head.value("Content-Type") ?? responseHead?.value("Content-Type") ?? "", pid: owner.pid, bundleID: owner.bundleID, appName: owner.appName, agent: owner.agent, agentName: owner.agentName, mcpServer: owner.mcpServer, toolCalls: calls, toolResults: results, mcp: mcp, llm: llm, note: notes.isEmpty ? nil : notes.joined(separator: " "), - mockRule: request.mock, guardrail: request.guardrail, responseTransform: request.responseTransform) + mockRule: request.mock, guardrail: request.guardrail, requestRewrite: request.requestRewrite, + responseTransform: request.responseTransform) onExchange(exchange) } } diff --git a/Flowlight/Inspection/MockGate.swift b/Flowlight/Inspection/MockGate.swift index 04fec360..05b3266c 100644 --- a/Flowlight/Inspection/MockGate.swift +++ b/Flowlight/Inspection/MockGate.swift @@ -18,13 +18,13 @@ final class MockGate { case answer(MockRule, Data, ProxyRequestHead?) /// A whole request, changed on its way out: the bytes to send and record, and what to call the change. /// The original is not recorded, because it is not what the server was sent. - case rewritten(Data, String) + case rewritten(Data, guardrail: String?, rewrite: String?) } /// What to do with a whole request once its body has arrived. enum Intervention: Equatable { /// Send and record these bytes instead. - case replace(Data, note: String) + case replace(Data, guardrail: String?, rewrite: String?) /// Answer it here; nothing goes upstream. case answer(MockRule) } @@ -145,8 +145,8 @@ final class MockGate { guard let held = holding else { return } holding = nil switch intervene?(held.head, held.bytes) { - case .replace(let bytes, let note): - actions.append(.rewritten(bytes, note)) + case .replace(let bytes, let guardrail, let rewrite): + actions.append(.rewritten(bytes, guardrail: guardrail, rewrite: rewrite)) case .answer(let rule): actions.append(.answer(rule, held.bytes, held.head)) case nil: diff --git a/Flowlight/Inspection/MockRule.swift b/Flowlight/Inspection/MockRule.swift index cb96e980..3239c17f 100644 --- a/Flowlight/Inspection/MockRule.swift +++ b/Flowlight/Inspection/MockRule.swift @@ -70,6 +70,9 @@ struct MockRule: Codable, Equatable, Identifiable, Sendable { /// activity to a later definition of the same rule. Ordinary saved mocks leave both values empty. var ruleSession: String? var ruleGeneration: UInt64? + /// Stable policy metadata recorded on the exchange. Kept separate from `name`, which is sent to the client in + /// `X-Flowlight-Mock` and must remain the human-readable title the configured mock promised. + var recordingReference: String? /// Methods offered in the editor. `ANY` is the empty method. static let methods = ["ANY", "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] @@ -93,11 +96,12 @@ struct MockRule: Codable, Equatable, Identifiable, Sendable { init(id: UUID = UUID(), enabled: Bool = true, name: String = "", host: String = "", path: String = "*", method: String = "", status: Int = 500, headers: [HTTPHeader] = [], body: String = "", bodyEncoding: MockBodyEncoding = .text, delay: Double = 0, blocked: Bool = false, - ruleSession: String? = nil, ruleGeneration: UInt64? = nil) { + ruleSession: String? = nil, ruleGeneration: UInt64? = nil, recordingReference: String? = nil) { self.id = id; self.enabled = enabled; self.name = name; self.host = host; self.path = path self.method = method; self.status = status; self.headers = headers; self.body = body self.bodyEncoding = bodyEncoding; self.delay = delay; self.blocked = blocked self.ruleSession = ruleSession; self.ruleGeneration = ruleGeneration + self.recordingReference = recordingReference } } @@ -120,6 +124,7 @@ extension MockRule { blocked = try c.decodeIfPresent(Bool.self, forKey: .blocked) ?? false ruleSession = try c.decodeIfPresent(String.self, forKey: .ruleSession) ruleGeneration = try c.decodeIfPresent(UInt64.self, forKey: .ruleGeneration) + recordingReference = try c.decodeIfPresent(String.self, forKey: .recordingReference) } } diff --git a/Flowlight/Inspection/ResponseScriptRunner.swift b/Flowlight/Inspection/ResponseScriptRunner.swift index 01818b10..fe7d39b1 100644 --- a/Flowlight/Inspection/ResponseScriptRunner.swift +++ b/Flowlight/Inspection/ResponseScriptRunner.swift @@ -8,7 +8,7 @@ final class ResponseScriptRunner: @unchecked Sendable { static let maxPayloadBytes = ResponseGate.holdLimit static let timeout: TimeInterval = 0.25 - struct Result: Sendable { var body: Data; var note: String } + struct Result: Sendable { var body: Data; var note: String; var rules: [ResponseTransformRule] } private final class OutputCapture: @unchecked Sendable { private let lock = NSLock() @@ -27,16 +27,17 @@ final class ResponseScriptRunner: @unchecked Sendable { guard responseBody.count <= Self.maxPayloadBytes, let representation = ResponseRepresentation.detect(headers: responseHead.headers, body: responseBody) else { return nil } var body = responseBody - var names: [String] = [] + var applied: [ResponseTransformRule] = [] for rule in rules { guard rule.script.lengthOfBytes(using: .utf8) <= Self.maxScriptBytes, let transformed = run(rule: rule, request: request, representation: representation, body: body), - transformed.count <= Self.maxPayloadBytes else { continue } + transformed.count <= Self.maxPayloadBytes, + transformed != body else { continue } body = transformed - names.append(rule.title) + applied.append(rule) } - guard !names.isEmpty, body != responseBody else { return nil } - return Result(body: body, note: names.joined(separator: " · ")) + guard !applied.isEmpty, body != responseBody else { return nil } + return Result(body: body, note: applied.map(\.title).joined(separator: " · "), rules: applied) #endif } diff --git a/Flowlight/Inspection/RewriteRule.swift b/Flowlight/Inspection/RewriteRule.swift index 1be82dbf..ede93736 100644 --- a/Flowlight/Inspection/RewriteRule.swift +++ b/Flowlight/Inspection/RewriteRule.swift @@ -1,5 +1,35 @@ import Foundation +/// Stable policy identity stored with inspected exchange activity. Titles remain useful after a policy is renamed or +/// deleted, while IDs keep Activity navigation independent of mutable display text. Legacy plain strings still decode +/// as nil and are handled by their existing title-based fallback. +struct InspectionPolicyReference: Equatable, Sendable { + struct Entry: Codable, Equatable, Sendable { + var id: UUID + var title: String + } + + private static let prefix = "flowlight-policy-v1:" + + static func encode(_ entries: [Entry]) -> String? { + var seen = Set() + let unique = entries.filter { seen.insert($0.id).inserted } + guard !unique.isEmpty, let data = try? JSONEncoder().encode(unique) else { return nil } + return prefix + data.base64EncodedString() + } + + static func decode(_ value: String) -> [Entry]? { + guard value.hasPrefix(prefix), + let data = Data(base64Encoded: String(value.dropFirst(prefix.count))), + let entries = try? JSONDecoder().decode([Entry].self, from: data), !entries.isEmpty else { return nil } + return entries + } + + static func display(_ value: String) -> String { + decode(value)?.map(\.title).joined(separator: " · ") ?? value + } +} + /// An edit to one outgoing header: replace it (`set`), append another copy (`add`), or drop it (`remove`). struct HeaderEdit: Codable, Equatable, Identifiable, Sendable { enum Op: String, Codable, Sendable, CaseIterable { case set, add, remove } @@ -123,7 +153,7 @@ enum RewriteRules { /// Apply every rule that matches this request to the framed bytes (full head + body). Returns the rewritten /// request and a short note of what changed, or nil when nothing matched or nothing changed. static func apply(_ rules: [RewriteRule], to request: Data, host: String, method: String, path: String) - -> (data: Data, note: String)? { + -> (data: Data, note: String, rules: [RewriteRule])? { let applicable = rules.filter { $0.enabled && GlobMatch.host($0.host, host) && GlobMatch.method($0.method, method) && GlobMatch.path($0.path, path) } @@ -137,57 +167,75 @@ enum RewriteRules { var headerLines = lines var bodyData = Data(request[sep.upperBound...]) var notes: [String] = [] - - for edit in applicable.flatMap(\.headers) { - // Sanitise both halves: a stray newline in a header value would forge extra headers or a second request. - let name = MockRule.headerSafe(edit.name) - guard !name.isEmpty else { continue } - let value = MockRule.headerSafe(edit.value) - let prefix = name.lowercased() + ":" - switch edit.op { - case .remove: - let before = headerLines.count - headerLines.removeAll { $0.lowercased().hasPrefix(prefix) } - if headerLines.count != before { notes.append("removed \(name)") } - case .set: - headerLines.removeAll { $0.lowercased().hasPrefix(prefix) } - headerLines.append("\(name): \(value)") - notes.append("set \(name)") - case .add: - headerLines.append("\(name): \(value)") - notes.append("added \(name)") + var changedRuleIDs: Set = [] + + for rule in applicable { + for edit in rule.headers { + // Sanitise both halves: a stray newline in a header value would forge extra headers or a second request. + let name = MockRule.headerSafe(edit.name) + guard !name.isEmpty else { continue } + let value = MockRule.headerSafe(edit.value) + let prefix = name.lowercased() + ":" + let before = headerLines + switch edit.op { + case .remove: + headerLines.removeAll { $0.lowercased().hasPrefix(prefix) } + if headerLines != before { notes.append("removed \(name)") } + case .set: + headerLines.removeAll { $0.lowercased().hasPrefix(prefix) } + headerLines.append("\(name): \(value)") + if headerLines != before { notes.append("set \(name)") } + case .add: + headerLines.append("\(name): \(value)") + notes.append("added \(name)") + } + if headerLines != before { changedRuleIDs.insert(rule.id) } } } - let bodyEdits = applicable.flatMap(\.body) - if !bodyEdits.isEmpty, !bodyData.isEmpty { + if applicable.contains(where: { !$0.body.isEmpty }), !bodyData.isEmpty { if var json = (try? JSONSerialization.jsonObject(with: bodyData)) as? [String: Any] { - var changed = false - for edit in bodyEdits { - let comps = edit.path.split(separator: ".").map(String.init) - guard !comps.isEmpty else { continue } - switch edit.op { - case .set: setJSON(&json, path: comps, value: parseValue(edit.value)); changed = true; notes.append("set \(edit.path)") - case .remove: removeJSON(&json, path: comps); changed = true; notes.append("removed \(edit.path)") + var bodyChanged = false + for rule in applicable { + for edit in rule.body { + let comps = edit.path.split(separator: ".").map(String.init) + guard !comps.isEmpty, + let before = try? JSONSerialization.data(withJSONObject: json, options: .sortedKeys) else { + continue + } + switch edit.op { + case .set: setJSON(&json, path: comps, value: parseValue(edit.value)) + case .remove: removeJSON(&json, path: comps) + } + guard let after = try? JSONSerialization.data(withJSONObject: json, options: .sortedKeys), + after != before else { continue } + changedRuleIDs.insert(rule.id) + bodyChanged = true + notes.append(edit.op == .set ? "set \(edit.path)" : "removed \(edit.path)") } } - if changed, let out = try? JSONSerialization.data(withJSONObject: json) { bodyData = out } + if bodyChanged, let out = try? JSONSerialization.data(withJSONObject: json) { bodyData = out } } else if isFormEncoded(headerLines), var form = FormBody(bodyData) { // A form body is flat, so a path is a field name taken whole — `a.b` means a field literally // named `a.b`, not a nested one. The value is always text; forms have no types, so it is used // verbatim rather than through `parseValue`. - var changed = false - for edit in bodyEdits { - let field = edit.path - guard !field.isEmpty else { continue } - switch edit.op { - case .set: - form.set(field, to: edit.value); changed = true; notes.append("set \(field)") - case .remove: - if form.remove(field) { changed = true; notes.append("removed \(field)") } + var bodyChanged = false + for rule in applicable { + for edit in rule.body { + let field = edit.path + guard !field.isEmpty else { continue } + let before = form.encoded() + switch edit.op { + case .set: form.set(field, to: edit.value) + case .remove: _ = form.remove(field) + } + guard form.encoded() != before else { continue } + changedRuleIDs.insert(rule.id) + bodyChanged = true + notes.append(edit.op == .set ? "set \(field)" : "removed \(field)") } } - if changed { bodyData = form.encoded() } + if bodyChanged { bodyData = form.encoded() } } } @@ -199,7 +247,7 @@ enum RewriteRules { var out = Data(([requestLine] + headerLines).joined(separator: "\r\n").utf8) out.append(Data("\r\n\r\n".utf8)) out.append(bodyData) - return (out, notes.joined(separator: ", ")) + return (out, notes.joined(separator: ", "), applicable.filter { changedRuleIDs.contains($0.id) }) } /// Parse a value cell as JSON when it can be (number, bool, null, object, array, quoted string); otherwise take diff --git a/Flowlight/Localization/de.lproj/Localizable.strings b/Flowlight/Localization/de.lproj/Localizable.strings index b5a39be4..bffb5663 100644 --- a/Flowlight/Localization/de.lproj/Localizable.strings +++ b/Flowlight/Localization/de.lproj/Localizable.strings @@ -372,7 +372,6 @@ /* Inspection/InspectionController.swift */ "AI agents and their tools" = "KI-Agenten und ihre Tools"; "Every app that uses the proxy" = "Jede App, die den Proxy nutzt"; -"Removed %@" = "%@ entfernt"; "No network services to send through the proxy, so nothing would be inspected. Check System Settings › Network." = "Keine Netzwerkdienste, die über den Proxy geleitet werden könnten, es würde also nichts inspiziert. Prüfen Sie Systemeinstellungen › Netzwerk."; "authorization failed" = "Autorisierung fehlgeschlagen"; "Couldn't set Flowlight up: %@" = "Flowlight konnte nicht eingerichtet werden: %@"; @@ -423,24 +422,11 @@ "Last hour" = "Letzte Stunde"; "24 hours" = "24 Stunden"; "7 days" = "7 Tage"; -"Observed AI agent activity, including destinations outside each agent's model provider." = "Beobachtete Aktivität von KI-Agenten, einschließlich Ziele außerhalb des jeweiligen Modellanbieters."; "Window" = "Zeitraum"; -"No AI agents seen" = "Keine KI-Agenten gesehen"; -"Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider." = "Flowlight erkennt unterstützte Agenten wie Claude Code, Codex, Cursor und Ollama. Außerdem klassifiziert es Apps, die keine Browser sind und einen bekannten LLM-API-Anbieter kontaktieren."; -"Agents active" = "Aktive Agenten"; -"AI API traffic" = "KI-API-Verkehr"; -"Uploaded to other hosts" = "An andere Hosts geladen"; -"Using sensitive channels" = "Nutzen sensible Kanäle"; -"Agent alerts" = "Agenten-Warnungen"; "Agent" = "Agent"; "Detected: calls an AI API" = "Erkannt: ruft eine KI-API auf"; "AI providers" = "KI-Anbieter"; -"AI traffic" = "KI-Verkehr"; -"Other hosts" = "Andere Hosts"; -"Sent elsewhere" = "Anderswohin gesendet"; -"%@ of this agent's uploads went to non-AI hosts" = "%@ der Uploads dieses Agenten gingen an Nicht-KI-Hosts"; "Allowlist" = "Erlaubnisliste"; -"Risk" = "Risiko"; "Show Traffic in History" = "Datenverkehr im Verlauf zeigen"; "None seen" = "Keine gesehen"; "Large upload" = "Großer Upload"; @@ -558,12 +544,6 @@ "No %@ in this window. Flowlight is decrypting %@'s traffic; its model just hasn't asked for any." = "Keine %@ in diesem Zeitraum. Flowlight entschlüsselt den Datenverkehr von %@; sein Modell hat nur keine angefordert."; /* UI/AlertsView.swift */ -"No alerts" = "Keine Warnungen"; -"Nothing to review" = "Nichts zu prüfen"; -"Each app has a learning period (24 h by default) before first-contact and spike rules fire." = "Jede App hat eine Lernphase (standardmäßig 24 Std.), bevor Regeln zu Erstkontakt und Spitzen auslösen."; -"All matching alerts have been acknowledged." = "Alle passenden Warnungen sind bestätigt."; -"%lld alerts, all acknowledged" = "%lld Warnungen, alle bestätigt"; -"%lld to review of %lld" = "%lld von %lld zu prüfen"; "Critical" = "Kritisch"; "Warning" = "Warnung"; "Info" = "Info"; @@ -580,13 +560,7 @@ "Acknowledge" = "Bestätigen"; "Copy Detail" = "Details kopieren"; "All rules" = "Alle Regeln"; -"Show acknowledged" = "Bestätigte anzeigen"; "Filter which alerts are listed" = "Filtern, welche Warnungen gelistet werden"; -"Selected (%lld)" = "Ausgewählte (%lld)"; -"All unacknowledged (%lld)" = "Alle unbestätigten (%lld)"; -"Mark alerts as seen" = "Warnungen als gesehen markieren"; -"All rules, including seen" = "Alle Regeln, auch gesehene"; -"%@, including seen" = "%@, auch gesehene"; /* UI/AskView.swift */ "%@ · leaves this Mac" = "%@ · verlässt diesen Mac"; @@ -752,8 +726,6 @@ "Seen as it happened, named, and readable." = "Beim Entstehen gesehen, benannt und lesbar."; /* UI/DevicesView.swift */ -"USB" = "USB"; -"Not watching" = "Nicht überwacht"; "Read the device list again now (⌘R)" = "Geräteliste jetzt neu lesen (⌘R)"; "Watch Bluetooth" = "Bluetooth überwachen"; "Watch USB and external storage" = "USB und externe Speicher überwachen"; @@ -1234,13 +1206,8 @@ "Edit as normal rule…" = "Als normale Regel bearbeiten…"; /* UI/RulesView.swift */ -"Packs" = "Pakete"; -"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "Regelentscheidungen und zusammengefasste Paketblockierungen erscheinen hier. Paketaktivitäten werden gruppiert, damit eine Seite voller Werbung nicht zu einer Seite voller Warnungen wird."; "Managed packs" = "Verwaltete Pakete"; -"What they did" = "Wirkung"; "Paused until %@" = "Pausiert bis %@"; -"%lld in force" = "%lld in Kraft"; -"%1$lld of %2$lld in force" = "%1$lld von %2$lld in Kraft"; "Templates" = "Vorlagen"; "Rules worth having, grouped by what they are for, with the hosts already written out" = "Regeln, die sich lohnen, nach Zweck gruppiert und mit den Hosts schon eingetragen"; "Write a rule: an app, an agent, a destination or a URL, blocked or allowed" = "Eine Regel schreiben: App, Agent, Ziel oder URL, blockiert oder erlaubt"; @@ -1254,8 +1221,6 @@ "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld Regeln werden beobachtet, aber nicht durchgesetzt. Nur die Netzwerkerweiterung sitzt im Datenpfad und kann eine Verbindung verweigern; der nettop-Sampler zählt Datenverkehr im Nachhinein. Wählen Sie unter „Quellen › Collector“ die Netzwerkerweiterung, um diese Regeln durchzusetzen."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "Eine Regel wird beobachtet, aber nicht durchgesetzt – eine Regel, die einen Pfad benennt, lässt sich nur mit HTTPS-Inspektion abgleichen, und die ist aus."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld Regeln werden beobachtet, aber nicht durchgesetzt – eine Regel, die einen Pfad benennt, lässt sich nur mit HTTPS-Inspektion abgleichen, und die ist aus."; -"In force" = "In Kraft"; -"Not in force" = "Nicht in Kraft"; "Nothing is being refused until %@." = "Bis %@ wird nichts verweigert."; "Resume now" = "Jetzt fortsetzen"; "No rules yet" = "Noch keine Regeln"; @@ -1265,7 +1230,6 @@ "Nothing yet" = "Noch nichts"; "Today" = "Heute"; "Yesterday" = "Gestern"; -"A rule since deleted" = "Eine inzwischen gelöschte Regel"; "Edit…" = "Bearbeiten…"; "Reset Count" = "Zähler zurücksetzen"; "Delete" = "Löschen"; @@ -1887,3 +1851,192 @@ "Restart or reinstall the filter to finish the update" = "Filter neu starten oder neu installieren, um die Aktualisierung abzuschließen"; "Return to the previous history scope" = "Zum vorherigen Verlaufsbereich zurückkehren"; "Selected" = "Ausgewählt"; + +/* 0.17.4 vereinheitlichte Richtlinien und Quellen */ +"Policies" = "Richtlinien"; +"Activity" = "Aktivität"; +"Choose the traffic source and verify that data is arriving." = "Wähle die Datenquelle aus und prüfe, ob Daten eintreffen."; +"Choose which local device channels Flowlight watches." = "Wähle aus, welche lokalen Gerätekanäle Flowlight überwacht."; +"Check what Flowlight can name, attribute, and inspect." = "Prüfe, was Flowlight benennen, zuordnen und inspizieren kann."; +"Network rules" = "Netzwerkregeln"; +"Agent allowlists" = "Agenten-Erlaubnislisten"; +"Agent guardrails" = "Schutzregeln für Agenten"; +"Request rewrites" = "Anfrageumschreibungen"; +"Response transforms" = "Antworttransformationen"; +"In effect" = "Wirksam"; +"Reporting only" = "Nur Meldungen"; +"Waiting" = "Wartet"; +"Unavailable" = "Nicht verfügbar"; +"Not applying" = "Wird nicht angewendet"; +"HTTPS inspection is off." = "Die HTTPS-Inspektion ist ausgeschaltet."; +"HTTPS inspection is not running." = "Die HTTPS-Inspektion läuft nicht."; +"This policy is switched off." = "Diese Richtlinie ist ausgeschaltet."; +"This rule is switched off." = "Diese Regel ist ausgeschaltet."; +"Network blocking is paused." = "Die Netzwerkblockierung ist pausiert."; +"This one-off rule has already been used." = "Diese einmalige Regel wurde bereits verwendet."; +"This rule is outside its scheduled time." = "Diese Regel liegt außerhalb ihrer geplanten Zeit."; +"Every destination" = "Jedes Ziel"; +"No listed destinations" = "Keine aufgeführten Ziele"; +"1 listed destination" = "1 aufgeführtes Ziel"; +"%lld listed destinations" = "%lld aufgeführte Ziele"; +"This allowlist is switched off." = "Diese Erlaubnisliste ist ausgeschaltet."; +"Unlisted destinations raise alerts but are not blocked." = "Nicht aufgeführte Ziele lösen Warnungen aus, werden aber nicht blockiert."; +"Blocking is unavailable; unlisted destinations still raise alerts." = "Blockieren ist nicht verfügbar; nicht aufgeführte Ziele lösen weiterhin Warnungen aus."; +"Review the changed pack before it can keep applying." = "Prüfe das geänderte Paket, bevor es weiter angewendet werden kann."; +"This pack is switched off." = "Dieses Paket ist ausgeschaltet."; +"The pack is matching destinations but cannot block them here." = "Das Paket erkennt passende Ziele, kann sie hier aber nicht blockieren."; +"The pack is waiting for the active filter to apply it." = "Das Paket wartet darauf, vom aktiven Filter angewendet zu werden."; +"The managed-pack engine is not applying this configuration." = "Die Engine für verwaltete Pakete wendet diese Konfiguration nicht an."; +"No longer in the official catalog" = "Nicht mehr im offiziellen Katalog"; +"Blocking stopped automatically. Acknowledge this change after review." = "Die Blockierung wurde automatisch beendet. Bestätige diese Änderung nach der Prüfung."; +"Every agent" = "Jeder Agent"; +"A policy since deleted" = "Eine inzwischen gelöschte Richtlinie"; +"%@ · %lld connections" = "%@ · %lld Verbindungen"; +"%lld policies · none currently effective" = "%lld Richtlinien · derzeit keine wirksam"; +"%1$lld policies · %2$lld currently effective" = "%1$lld Richtlinien · %2$lld derzeit wirksam"; +"Everything Flowlight can apply" = "Alles, was Flowlight anwenden kann"; +"Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent." = "Netzwerkentscheidungen, Agentenbeschränkungen, verwaltete Ziellisten und Richtlinien für die Entwicklerinspektion sind hier zusammengefasst. Jede Richtlinie öffnet sich weiterhin in dem Editor, der ihr Verhalten und ihre Einwilligung verwaltet."; +"Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent." = "Netzwerkentscheidungen, Agentenbeschränkungen und verwaltete Ziellisten sind hier zusammengefasst. Jede Richtlinie öffnet sich weiterhin in dem Editor, der ihr Verhalten und ihre Einwilligung verwaltet."; +"%lld destinations · %@" = "%lld Ziele · %@"; +"every app" = "jede App"; +"Official · pack v%@ · %@" = "Offiziell · Paket v%@ · %@"; +"Flowlight · signer %@" = "Flowlight · Signaturgeber %@"; +"Catalog" = "Katalog"; +"Sequence %1$lld · digest %2$@" = "Sequenz %1$lld · Prüfsumme %2$@"; +"Last verified" = "Zuletzt verifiziert"; +"Next check" = "Nächste Prüfung"; +"Official catalog details" = "Details zum offiziellen Katalog"; +"Not checked" = "Nicht geprüft"; +"Verified" = "Verifiziert"; +"Stale" = "Veraltet"; +"Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records." = "Blockierungen, Ausnahmen, Verstöße gegen Erlaubnislisten, Schutzregeln, Mock-Antworten sowie Änderungen an Anfragen oder Antworten erscheinen hier, ohne ihre ursprünglichen Einträge zu verändern."; +"Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records." = "Netzwerkblockierungen, Ausnahmen, Treffer verwalteter Pakete und Verstöße gegen Erlaubnislisten erscheinen hier, ohne ihre ursprünglichen Einträge zu verändern."; +"Blocked connection or request" = "Blockierte Verbindung oder Anfrage"; +"Blocked by a managed pack" = "Von einem verwalteten Paket blockiert"; +"Outside an agent allowlist" = "Außerhalb einer Agenten-Erlaubnisliste"; +"Capability removed by a guardrail" = "Funktion durch eine Schutzregel entfernt"; +"Answered by a mock" = "Mit einer Mock-Antwort beantwortet"; +"Request changed before sending" = "Anfrage vor dem Senden geändert"; +"Response changed before delivery" = "Antwort vor der Zustellung geändert"; + +/* 0.17.5 Anbieterprofile, Agentenstatus und Warnungstriage */ +"Couldn't update alerts: %@" = "Warnungen konnten nicht aktualisiert werden: %@"; +"Needs attention" = "Aufmerksamkeit erforderlich"; +"Ready" = "Bereit"; +"This provider profile is disabled." = "Dieses Anbieterprofil ist deaktiviert."; +"Assign at least one inspection plugin." = "Weise mindestens ein Inspektions-Plugin zu."; +"An assigned plugin is no longer installed." = "Ein zugewiesenes Plugin ist nicht mehr installiert."; +"An assigned plugin uses a different provider." = "Ein zugewiesenes Plugin verwendet einen anderen Anbieter."; +"Every assigned plugin is disabled." = "Alle zugewiesenen Plugins sind deaktiviert."; +"1 inspection plugin assigned." = "1 Inspektions-Plugin zugewiesen."; +"%lld inspection plugins assigned." = "%lld Inspektions-Plugins zugewiesen."; +"Enter a provider profile name between 1 and 80 characters." = "Gib einen Namen für das Anbieterprofil mit 1 bis 80 Zeichen ein."; +"Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed." = "Verwende einen HTTPS-Endpunkt oder HTTP nur für localhost. Benutzerinformationen, Abfrageparameter und Fragmente sind nicht zulässig."; +"Use a Keychain reference such as keychain:service/account, not a credential value." = "Verwende eine Schlüsselbundreferenz wie keychain:service/account und keine Zugangsdaten."; +"Provider options must be short, non-secret key/value metadata." = "Anbieteroptionen müssen kurze, nicht geheime Schlüssel-Wert-Metadaten sein."; +"An assigned plugin identifier is invalid." = "Die Kennung eines zugewiesenen Plugins ist ungültig."; +"Provider profiles could not be loaded. Create them again in Inspect." = "Anbieterprofile konnten nicht geladen werden. Erstelle sie unter „Inspizieren“ erneut."; +"Healthy" = "Fehlerfrei"; +"Needs setup" = "Einrichtung erforderlich"; +"Limited visibility" = "Eingeschränkte Sichtbarkeit"; +"Attention" = "Achtung"; +"Not observed" = "Nicht beobachtet"; +"Detected agent" = "Erkannter Agent"; +"Traffic was observed in the selected window." = "Im ausgewählten Zeitraum wurde Datenverkehr beobachtet."; +"Configuration was found, but no traffic was observed in the selected window." = "Eine Konfiguration wurde gefunden, aber im ausgewählten Zeitraum wurde kein Datenverkehr beobachtet."; +"Installed on this Mac, with no traffic in the selected window." = "Auf diesem Mac installiert, ohne Datenverkehr im ausgewählten Zeitraum."; +"No traffic was observed in the selected window." = "Im ausgewählten Zeitraum wurde kein Datenverkehr beobachtet."; +"Always Monitor is configured, but no current route was observed." = "Always Monitor ist konfiguriert, aber es wurde keine aktuelle Route beobachtet."; +"Its route through Flowlight was observed." = "Die Route durch Flowlight wurde beobachtet."; +"The saved Flowlight route was not observed in this window." = "Die gespeicherte Flowlight-Route wurde in diesem Zeitraum nicht beobachtet."; +"Flowlight could not establish the requested route." = "Flowlight konnte die angeforderte Route nicht herstellen."; +"HTTPS inspection is unavailable in this build; only connection metadata is visible." = "Die HTTPS-Inspektion ist in diesem Build nicht verfügbar; nur Verbindungsmetadaten sind sichtbar."; +"HTTPS inspection is off; request contents are not visible." = "Die HTTPS-Inspektion ist ausgeschaltet; Anfrageinhalte sind nicht sichtbar."; +"The HTTPS inspection certificate is not trusted." = "Das Zertifikat der HTTPS-Inspektion ist nicht vertrauenswürdig."; +"HTTPS inspection is enabled, but its proxy is not ready." = "Die HTTPS-Inspektion ist aktiviert, aber ihr Proxy ist nicht bereit."; +"No inspected requests were observed in this window." = "In diesem Zeitraum wurden keine inspizierten Anfragen beobachtet."; +"HTTPS inspection observed this agent's traffic." = "Die HTTPS-Inspektion hat den Datenverkehr dieses Agenten beobachtet."; +"One observed destination is outside its policy." = "Ein beobachtetes Ziel liegt außerhalb seiner Richtlinie."; +"%lld observed destinations are outside its policy." = "%lld beobachtete Ziele liegen außerhalb seiner Richtlinie."; +"Its enforcing destination policy covers the observed traffic." = "Seine durchsetzende Zielrichtlinie deckt den beobachteten Datenverkehr ab."; +"Its reporting destination policy covers the observed traffic." = "Seine meldende Zielrichtlinie deckt den beobachteten Datenverkehr ab."; +"No agent destination policy is enabled." = "Keine Zielrichtlinie für Agenten ist aktiviert."; +"One unresolved alert needs review." = "Eine ungelöste Warnung muss geprüft werden."; +"%lld unresolved alerts need review." = "%lld ungelöste Warnungen müssen geprüft werden."; +"Installed, configured, and observed AI agents, with explainable setup and visibility health." = "Installierte, konfigurierte und beobachtete KI-Agenten mit nachvollziehbarem Einrichtungs- und Sichtbarkeitsstatus."; +"No AI agents found" = "Keine KI-Agenten gefunden"; +"Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider." = "Flowlight inventarisiert unterstützte Apps und konfigurierte Kommandozeilen-Agenten und erkennt Clients, die keine Browser sind und einen bekannten KI-Anbieter kontaktieren."; +"Agents inventoried" = "Inventarisierte Agenten"; +"Active in window" = "Im Zeitraum aktiv"; +"Health" = "Status"; +"Why" = "Warum"; +"Policy" = "Richtlinie"; +"Not set" = "Nicht eingerichtet"; +"Enforcing" = "Wirksam"; +"Reporting" = "Nur Meldungen"; +"No traffic detail is available for this agent in the selected window." = "Für diesen Agenten sind im ausgewählten Zeitraum keine Verkehrsdaten verfügbar."; +"No matching alerts" = "Keine passenden Warnungen"; +"Nothing matches this rule" = "Nichts entspricht dieser Regel"; +"State" = "Status"; +"Disposition" = "Einstufung"; +"Acknowledge all new (%lld)" = "Alle neuen bestätigen (%lld)"; +"Triage" = "Triage"; +"Update the selected alerts" = "Ausgewählte Warnungen aktualisieren"; +"Set State" = "Status festlegen"; +"Set Disposition" = "Einstufung festlegen"; +"Edit Note…" = "Notiz bearbeiten…"; +"Triage Note" = "Triage-Notiz"; +"%lld open, no new" = "%lld offen, keine neuen"; +"%lld new of %lld open" = "%lld neu von %lld offen"; +"%lld investigating" = "%lld in Untersuchung"; +"%lld resolved" = "%lld gelöst"; +"%lld dismissed" = "%lld verworfen"; +"No open alerts match the current Focus scope." = "Keine offenen Warnungen entsprechen dem aktuellen Fokusbereich."; +"No alerts are being investigated in the current Focus scope." = "Im aktuellen Fokusbereich werden keine Warnungen untersucht."; +"No resolved alerts match the current Focus scope." = "Keine gelösten Warnungen entsprechen dem aktuellen Fokusbereich."; +"No dismissed alerts match the current Focus scope." = "Keine verworfenen Warnungen entsprechen dem aktuellen Fokusbereich."; +"New" = "Neu"; +"Acknowledged" = "Bestätigt"; +"Investigating" = "In Untersuchung"; +"Resolved" = "Gelöst"; +"Dismissed" = "Verworfen"; +"Unset" = "Nicht festgelegt"; +"True positive" = "Richtig positiv"; +"Expected / benign" = "Erwartet / unbedenklich"; +"False positive" = "Falsch positiv"; +"Open" = "Offen"; +"Plugins & Providers" = "Plugins & Anbieter"; +"Manage inspection plugins and local provider profiles" = "Inspektions-Plugins und lokale Anbieterprofile verwalten"; +"Live network traffic" = "Live-Netzwerkverkehr"; +"No time selected" = "Kein Zeitpunkt ausgewählt"; +"Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second." = "Ausgefüllte Kreise sind Downloads; gestrichelte Rauten sind Uploads. Verwende die Links- und Rechtspfeiltaste, um jede Sekunde zu untersuchen."; +"Download · solid" = "Download · ausgefüllter Kreis"; +"Upload · dashed" = "Upload · gestrichelte Raute"; +"Inspection extensions, local routing metadata, and retained findings." = "Inspektionserweiterungen, lokale Routing-Metadaten und gespeicherte Befunde."; +"No provider profiles" = "Keine Anbieterprofile"; +"Profiles group local provider settings with the inspection plugins that use them." = "Profile gruppieren lokale Anbietereinstellungen mit den Inspektions-Plugins, die sie verwenden."; +"Add Provider…" = "Anbieter hinzufügen…"; +"Provider profiles" = "Anbieterprofile"; +"Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile." = "Profile bleiben auf diesem Mac. Sie enthalten nicht geheime Optionen und optionale Schlüsselbundreferenzen; Flowlight speichert niemals Zugangsdaten in einem Profil."; +"Saved provider profile %@." = "Anbieterprofil %@ gespeichert."; +"Remove provider profile?" = "Anbieterprofil entfernen?"; +"Removed provider profile %@." = "Anbieterprofil %@ entfernt."; +"Could not remove provider profile." = "Anbieterprofil konnte nicht entfernt werden."; +"Plugins and their retained findings stay installed." = "Plugins und ihre gespeicherten Befunde bleiben installiert."; +"Credential value kept in Keychain" = "Zugangsdaten im Schlüsselbund gespeichert"; +"Add Provider Profile" = "Anbieterprofil hinzufügen"; +"Edit Provider Profile" = "Anbieterprofil bearbeiten"; +"Identity" = "Identität"; +"Enabled" = "Aktiviert"; +"Endpoint (optional)" = "Endpunkt (optional)"; +"Keychain reference (optional)" = "Schlüsselbundreferenz (optional)"; +"Use keychain:service/account. The credential value is never stored in this profile." = "Verwende keychain:service/account. Die Zugangsdaten werden nie in diesem Profil gespeichert."; +"No installed plugins use this provider." = "Keine installierten Plugins verwenden diesen Anbieter."; +"Plugin enabled" = "Plugin aktiviert"; +"Plugin disabled" = "Plugin deaktiviert"; +"Inspection plugins" = "Inspektions-Plugins"; +"Option" = "Option"; +"Remove provider option" = "Anbieteroption entfernen"; +"Add Option" = "Option hinzufügen"; +"Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected." = "Optionen sind lokale, nicht geheime Metadaten. API-Schlüssel, Tokens, Passwörter und andere Zugangsdaten werden abgelehnt."; +"Provider options" = "Anbieteroptionen"; diff --git a/Flowlight/Localization/en.lproj/Localizable.strings b/Flowlight/Localization/en.lproj/Localizable.strings index 89b0a0f2..1f31aa45 100644 --- a/Flowlight/Localization/en.lproj/Localizable.strings +++ b/Flowlight/Localization/en.lproj/Localizable.strings @@ -351,7 +351,6 @@ /* Inspection/InspectionController.swift */ "AI agents and their tools" = "AI agents and their tools"; "Every app that uses the proxy" = "Every app that uses the proxy"; -"Removed %@" = "Removed %@"; "No network services to send through the proxy, so nothing would be inspected. Check System Settings › Network." = "No network services to send through the proxy, so nothing would be inspected. Check System Settings › Network."; "authorization failed" = "authorization failed"; "Couldn't set Flowlight up: %@" = "Couldn't set Flowlight up: %@"; @@ -395,24 +394,11 @@ "Last hour" = "Last hour"; "24 hours" = "24 hours"; "7 days" = "7 days"; -"Observed AI agent activity, including destinations outside each agent's model provider." = "Observed AI agent activity, including destinations outside each agent's model provider."; "Window" = "Window"; -"No AI agents seen" = "No AI agents seen"; -"Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider." = "Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider."; -"Agents active" = "Agents active"; -"AI API traffic" = "AI API traffic"; -"Uploaded to other hosts" = "Uploaded to other hosts"; -"Using sensitive channels" = "Using sensitive channels"; -"Agent alerts" = "Agent alerts"; "Agent" = "Agent"; "Detected: calls an AI API" = "Detected: calls an AI API"; "AI providers" = "AI providers"; -"AI traffic" = "AI traffic"; -"Other hosts" = "Other hosts"; -"Sent elsewhere" = "Sent elsewhere"; -"%@ of this agent's uploads went to non-AI hosts" = "%@ of this agent's uploads went to non-AI hosts"; "Allowlist" = "Allowlist"; -"Risk" = "Risk"; "Show Traffic in History" = "Show Traffic in History"; "None seen" = "None seen"; "Large upload" = "Large upload"; @@ -530,12 +516,6 @@ "No %@ in this window. Flowlight is decrypting %@'s traffic; its model just hasn't asked for any." = "No %@ in this window. Flowlight is decrypting %@'s traffic; its model just hasn't asked for any."; /* UI/AlertsView.swift */ -"No alerts" = "No alerts"; -"Nothing to review" = "Nothing to review"; -"Each app has a learning period (24 h by default) before first-contact and spike rules fire." = "Each app has a learning period (24 h by default) before first-contact and spike rules fire."; -"All matching alerts have been acknowledged." = "All matching alerts have been acknowledged."; -"%lld alerts, all acknowledged" = "%lld alerts, all acknowledged"; -"%lld to review of %lld" = "%lld to review of %lld"; "Critical" = "Critical"; "Warning" = "Warning"; "Info" = "Info"; @@ -552,13 +532,7 @@ "Acknowledge" = "Acknowledge"; "Copy Detail" = "Copy Detail"; "All rules" = "All rules"; -"Show acknowledged" = "Show acknowledged"; "Filter which alerts are listed" = "Filter which alerts are listed"; -"Selected (%lld)" = "Selected (%lld)"; -"All unacknowledged (%lld)" = "All unacknowledged (%lld)"; -"Mark alerts as seen" = "Mark alerts as seen"; -"All rules, including seen" = "All rules, including seen"; -"%@, including seen" = "%@, including seen"; /* UI/AskView.swift */ "%@ · leaves this Mac" = "%@ · leaves this Mac"; @@ -724,8 +698,6 @@ "Seen as it happened, named, and readable." = "Seen as it happened, named, and readable."; /* UI/DevicesView.swift */ -"USB" = "USB"; -"Not watching" = "Not watching"; "Read the device list again now (⌘R)" = "Read the device list again now (⌘R)"; "Watch Bluetooth" = "Watch Bluetooth"; "Watch USB and external storage" = "Watch USB and external storage"; @@ -1166,13 +1138,8 @@ "Edit as normal rule…" = "Edit as normal rule…"; /* UI/RulesView.swift */ -"Packs" = "Packs"; -"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts."; "Managed packs" = "Managed packs"; -"What they did" = "What they did"; "Paused until %@" = "Paused until %@"; -"%lld in force" = "%lld in force"; -"%1$lld of %2$lld in force" = "%1$lld of %2$lld in force"; "Templates" = "Templates"; "Rules worth having, grouped by what they are for, with the hosts already written out" = "Rules worth having, grouped by what they are for, with the hosts already written out"; "Write a rule: an app, an agent, a destination or a URL, blocked or allowed" = "Write a rule: an app, an agent, a destination or a URL, blocked or allowed"; @@ -1186,8 +1153,6 @@ "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off."; -"In force" = "In force"; -"Not in force" = "Not in force"; "Nothing is being refused until %@." = "Nothing is being refused until %@."; "Resume now" = "Resume now"; "No rules yet" = "No rules yet"; @@ -1197,7 +1162,6 @@ "Nothing yet" = "Nothing yet"; "Today" = "Today"; "Yesterday" = "Yesterday"; -"A rule since deleted" = "A rule since deleted"; "Edit…" = "Edit…"; "Reset Count" = "Reset Count"; "Delete" = "Delete"; @@ -1886,3 +1850,192 @@ "Restart or reinstall the filter to finish the update" = "Restart or reinstall the filter to finish the update"; "Return to the previous history scope" = "Return to the previous history scope"; "Selected" = "Selected"; + +/* 0.17.4 unified policies and sources */ +"Policies" = "Policies"; +"Activity" = "Activity"; +"Choose the traffic source and verify that data is arriving." = "Choose the traffic source and verify that data is arriving."; +"Choose which local device channels Flowlight watches." = "Choose which local device channels Flowlight watches."; +"Check what Flowlight can name, attribute, and inspect." = "Check what Flowlight can name, attribute, and inspect."; +"Network rules" = "Network rules"; +"Agent allowlists" = "Agent allowlists"; +"Agent guardrails" = "Agent guardrails"; +"Request rewrites" = "Request rewrites"; +"Response transforms" = "Response transforms"; +"In effect" = "In effect"; +"Reporting only" = "Reporting only"; +"Waiting" = "Waiting"; +"Unavailable" = "Unavailable"; +"Not applying" = "Not applying"; +"HTTPS inspection is off." = "HTTPS inspection is off."; +"HTTPS inspection is not running." = "HTTPS inspection is not running."; +"This policy is switched off." = "This policy is switched off."; +"This rule is switched off." = "This rule is switched off."; +"Network blocking is paused." = "Network blocking is paused."; +"This one-off rule has already been used." = "This one-off rule has already been used."; +"This rule is outside its scheduled time." = "This rule is outside its scheduled time."; +"Every destination" = "Every destination"; +"No listed destinations" = "No listed destinations"; +"1 listed destination" = "1 listed destination"; +"%lld listed destinations" = "%lld listed destinations"; +"This allowlist is switched off." = "This allowlist is switched off."; +"Unlisted destinations raise alerts but are not blocked." = "Unlisted destinations raise alerts but are not blocked."; +"Blocking is unavailable; unlisted destinations still raise alerts." = "Blocking is unavailable; unlisted destinations still raise alerts."; +"Review the changed pack before it can keep applying." = "Review the changed pack before it can keep applying."; +"This pack is switched off." = "This pack is switched off."; +"The pack is matching destinations but cannot block them here." = "The pack is matching destinations but cannot block them here."; +"The pack is waiting for the active filter to apply it." = "The pack is waiting for the active filter to apply it."; +"The managed-pack engine is not applying this configuration." = "The managed-pack engine is not applying this configuration."; +"No longer in the official catalog" = "No longer in the official catalog"; +"Blocking stopped automatically. Acknowledge this change after review." = "Blocking stopped automatically. Acknowledge this change after review."; +"Every agent" = "Every agent"; +"A policy since deleted" = "A policy since deleted"; +"%@ · %lld connections" = "%@ · %lld connections"; +"%lld policies · none currently effective" = "%lld policies · none currently effective"; +"%1$lld policies · %2$lld currently effective" = "%1$lld policies · %2$lld currently effective"; +"Everything Flowlight can apply" = "Everything Flowlight can apply"; +"Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent." = "Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent."; +"Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent." = "Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent."; +"%lld destinations · %@" = "%lld destinations · %@"; +"every app" = "every app"; +"Official · pack v%@ · %@" = "Official · pack v%@ · %@"; +"Flowlight · signer %@" = "Flowlight · signer %@"; +"Catalog" = "Catalog"; +"Sequence %1$lld · digest %2$@" = "Sequence %1$lld · digest %2$@"; +"Last verified" = "Last verified"; +"Next check" = "Next check"; +"Official catalog details" = "Official catalog details"; +"Not checked" = "Not checked"; +"Verified" = "Verified"; +"Stale" = "Stale"; +"Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records." = "Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records."; +"Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records." = "Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records."; +"Blocked connection or request" = "Blocked connection or request"; +"Blocked by a managed pack" = "Blocked by a managed pack"; +"Outside an agent allowlist" = "Outside an agent allowlist"; +"Capability removed by a guardrail" = "Capability removed by a guardrail"; +"Answered by a mock" = "Answered by a mock"; +"Request changed before sending" = "Request changed before sending"; +"Response changed before delivery" = "Response changed before delivery"; + +/* 0.17.5 agent inventory, alert triage, and inspection providers */ +"Couldn't update alerts: %@" = "Couldn't update alerts: %@"; +"Needs attention" = "Needs attention"; +"Ready" = "Ready"; +"This provider profile is disabled." = "This provider profile is disabled."; +"Assign at least one inspection plugin." = "Assign at least one inspection plugin."; +"An assigned plugin is no longer installed." = "An assigned plugin is no longer installed."; +"An assigned plugin uses a different provider." = "An assigned plugin uses a different provider."; +"Every assigned plugin is disabled." = "Every assigned plugin is disabled."; +"1 inspection plugin assigned." = "1 inspection plugin assigned."; +"%lld inspection plugins assigned." = "%lld inspection plugins assigned."; +"Enter a provider profile name between 1 and 80 characters." = "Enter a provider profile name between 1 and 80 characters."; +"Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed." = "Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed."; +"Use a Keychain reference such as keychain:service/account, not a credential value." = "Use a Keychain reference such as keychain:service/account, not a credential value."; +"Provider options must be short, non-secret key/value metadata." = "Provider options must be short, non-secret key/value metadata."; +"An assigned plugin identifier is invalid." = "An assigned plugin identifier is invalid."; +"Provider profiles could not be loaded. Create them again in Inspect." = "Provider profiles could not be loaded. Create them again in Inspect."; +"Healthy" = "Healthy"; +"Needs setup" = "Needs setup"; +"Limited visibility" = "Limited visibility"; +"Attention" = "Attention"; +"Not observed" = "Not observed"; +"Detected agent" = "Detected agent"; +"Traffic was observed in the selected window." = "Traffic was observed in the selected window."; +"Configuration was found, but no traffic was observed in the selected window." = "Configuration was found, but no traffic was observed in the selected window."; +"Installed on this Mac, with no traffic in the selected window." = "Installed on this Mac, with no traffic in the selected window."; +"No traffic was observed in the selected window." = "No traffic was observed in the selected window."; +"Always Monitor is configured, but no current route was observed." = "Always Monitor is configured, but no current route was observed."; +"Its route through Flowlight was observed." = "Its route through Flowlight was observed."; +"The saved Flowlight route was not observed in this window." = "The saved Flowlight route was not observed in this window."; +"Flowlight could not establish the requested route." = "Flowlight could not establish the requested route."; +"HTTPS inspection is unavailable in this build; only connection metadata is visible." = "HTTPS inspection is unavailable in this build; only connection metadata is visible."; +"HTTPS inspection is off; request contents are not visible." = "HTTPS inspection is off; request contents are not visible."; +"The HTTPS inspection certificate is not trusted." = "The HTTPS inspection certificate is not trusted."; +"HTTPS inspection is enabled, but its proxy is not ready." = "HTTPS inspection is enabled, but its proxy is not ready."; +"No inspected requests were observed in this window." = "No inspected requests were observed in this window."; +"HTTPS inspection observed this agent's traffic." = "HTTPS inspection observed this agent's traffic."; +"One observed destination is outside its policy." = "One observed destination is outside its policy."; +"%lld observed destinations are outside its policy." = "%lld observed destinations are outside its policy."; +"Its enforcing destination policy covers the observed traffic." = "Its enforcing destination policy covers the observed traffic."; +"Its reporting destination policy covers the observed traffic." = "Its reporting destination policy covers the observed traffic."; +"No agent destination policy is enabled." = "No agent destination policy is enabled."; +"One unresolved alert needs review." = "One unresolved alert needs review."; +"%lld unresolved alerts need review." = "%lld unresolved alerts need review."; +"Installed, configured, and observed AI agents, with explainable setup and visibility health." = "Installed, configured, and observed AI agents, with explainable setup and visibility health."; +"No AI agents found" = "No AI agents found"; +"Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider." = "Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider."; +"Agents inventoried" = "Agents inventoried"; +"Active in window" = "Active in window"; +"Health" = "Health"; +"Why" = "Why"; +"Policy" = "Policy"; +"Not set" = "Not set"; +"Enforcing" = "Enforcing"; +"Reporting" = "Reporting"; +"No traffic detail is available for this agent in the selected window." = "No traffic detail is available for this agent in the selected window."; +"No matching alerts" = "No matching alerts"; +"Nothing matches this rule" = "Nothing matches this rule"; +"State" = "State"; +"Disposition" = "Disposition"; +"Acknowledge all new (%lld)" = "Acknowledge all new (%lld)"; +"Triage" = "Triage"; +"Update the selected alerts" = "Update the selected alerts"; +"Set State" = "Set State"; +"Set Disposition" = "Set Disposition"; +"Edit Note…" = "Edit Note…"; +"Triage Note" = "Triage Note"; +"%lld open, no new" = "%lld open, no new"; +"%lld new of %lld open" = "%lld new of %lld open"; +"%lld investigating" = "%lld investigating"; +"%lld resolved" = "%lld resolved"; +"%lld dismissed" = "%lld dismissed"; +"No open alerts match the current Focus scope." = "No open alerts match the current Focus scope."; +"No alerts are being investigated in the current Focus scope." = "No alerts are being investigated in the current Focus scope."; +"No resolved alerts match the current Focus scope." = "No resolved alerts match the current Focus scope."; +"No dismissed alerts match the current Focus scope." = "No dismissed alerts match the current Focus scope."; +"New" = "New"; +"Acknowledged" = "Acknowledged"; +"Investigating" = "Investigating"; +"Resolved" = "Resolved"; +"Dismissed" = "Dismissed"; +"Unset" = "Unset"; +"True positive" = "True positive"; +"Expected / benign" = "Expected / benign"; +"False positive" = "False positive"; +"Open" = "Open"; +"Plugins & Providers" = "Plugins & Providers"; +"Manage inspection plugins and local provider profiles" = "Manage inspection plugins and local provider profiles"; +"Live network traffic" = "Live network traffic"; +"No time selected" = "No time selected"; +"Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second." = "Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second."; +"Download · solid" = "Download · solid"; +"Upload · dashed" = "Upload · dashed"; +"Inspection extensions, local routing metadata, and retained findings." = "Inspection extensions, local routing metadata, and retained findings."; +"No provider profiles" = "No provider profiles"; +"Profiles group local provider settings with the inspection plugins that use them." = "Profiles group local provider settings with the inspection plugins that use them."; +"Add Provider…" = "Add Provider…"; +"Provider profiles" = "Provider profiles"; +"Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile." = "Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile."; +"Saved provider profile %@." = "Saved provider profile %@."; +"Remove provider profile?" = "Remove provider profile?"; +"Removed provider profile %@." = "Removed provider profile %@."; +"Could not remove provider profile." = "Could not remove provider profile."; +"Plugins and their retained findings stay installed." = "Plugins and their retained findings stay installed."; +"Credential value kept in Keychain" = "Credential value kept in Keychain"; +"Add Provider Profile" = "Add Provider Profile"; +"Edit Provider Profile" = "Edit Provider Profile"; +"Identity" = "Identity"; +"Enabled" = "Enabled"; +"Endpoint (optional)" = "Endpoint (optional)"; +"Keychain reference (optional)" = "Keychain reference (optional)"; +"Use keychain:service/account. The credential value is never stored in this profile." = "Use keychain:service/account. The credential value is never stored in this profile."; +"No installed plugins use this provider." = "No installed plugins use this provider."; +"Plugin enabled" = "Plugin enabled"; +"Plugin disabled" = "Plugin disabled"; +"Inspection plugins" = "Inspection plugins"; +"Option" = "Option"; +"Remove provider option" = "Remove provider option"; +"Add Option" = "Add Option"; +"Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected." = "Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected."; +"Provider options" = "Provider options"; diff --git a/Flowlight/Localization/es.lproj/Localizable.strings b/Flowlight/Localization/es.lproj/Localizable.strings index 3cda2c08..ab34dcf1 100644 --- a/Flowlight/Localization/es.lproj/Localizable.strings +++ b/Flowlight/Localization/es.lproj/Localizable.strings @@ -372,7 +372,6 @@ /* Inspection/InspectionController.swift */ "AI agents and their tools" = "Los agentes de IA y sus herramientas"; "Every app that uses the proxy" = "Todas las apps que usan el proxy"; -"Removed %@" = "Se ha quitado %@"; "No network services to send through the proxy, so nothing would be inspected. Check System Settings › Network." = "No hay servicios de red que enviar a través del proxy, así que no se inspeccionaría nada. Comprueba Ajustes del Sistema › Red."; "authorization failed" = "la autorización ha fallado"; "Couldn't set Flowlight up: %@" = "No se ha podido configurar Flowlight: %@"; @@ -423,24 +422,11 @@ "Last hour" = "Última hora"; "24 hours" = "24 horas"; "7 days" = "7 días"; -"Observed AI agent activity, including destinations outside each agent's model provider." = "Actividad observada de los agentes de IA, incluidos los destinos ajenos al proveedor de modelos de cada agente."; "Window" = "Periodo"; -"No AI agents seen" = "No se ha visto ningún agente de IA"; -"Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider." = "Flowlight reconoce los agentes compatibles, como Claude Code, Codex, Cursor y Ollama. También clasifica como agentes las apps que no son navegadores y contactan con un proveedor de API de LLM conocido."; -"Agents active" = "Agentes activos"; -"AI API traffic" = "Tráfico de API de IA"; -"Uploaded to other hosts" = "Subido a otros hosts"; -"Using sensitive channels" = "Usan canales sensibles"; -"Agent alerts" = "Alertas de agentes"; "Agent" = "Agente"; "Detected: calls an AI API" = "Detectado: llama a una API de IA"; "AI providers" = "Proveedores de IA"; -"AI traffic" = "Tráfico de IA"; -"Other hosts" = "Otros hosts"; -"Sent elsewhere" = "Enviado a otros sitios"; -"%@ of this agent's uploads went to non-AI hosts" = "%@ de las subidas de este agente fueron a hosts no IA"; "Allowlist" = "Lista de permitidos"; -"Risk" = "Riesgo"; "Show Traffic in History" = "Ver el tráfico en Historial"; "None seen" = "No se ha visto ninguno"; "Large upload" = "Subida grande"; @@ -558,12 +544,6 @@ "No %@ in this window. Flowlight is decrypting %@'s traffic; its model just hasn't asked for any." = "No hay %@ en este periodo. Flowlight está descifrando el tráfico de %@; simplemente su modelo no ha pedido ninguna."; /* UI/AlertsView.swift */ -"No alerts" = "Sin alertas"; -"Nothing to review" = "Nada que revisar"; -"Each app has a learning period (24 h by default) before first-contact and spike rules fire." = "Cada app tiene un periodo de aprendizaje (24 h por omisión) antes de que se activen las reglas de primer contacto y de picos."; -"All matching alerts have been acknowledged." = "Se han confirmado todas las alertas que coinciden."; -"%lld alerts, all acknowledged" = "%lld alertas, todas confirmadas"; -"%lld to review of %lld" = "%lld por revisar de %lld"; "Critical" = "Crítica"; "Warning" = "Advertencia"; "Info" = "Información"; @@ -580,13 +560,7 @@ "Acknowledge" = "Confirmar"; "Copy Detail" = "Copiar el detalle"; "All rules" = "Todas las reglas"; -"Show acknowledged" = "Mostrar las confirmadas"; "Filter which alerts are listed" = "Filtrar las alertas que se muestran"; -"Selected (%lld)" = "Seleccionadas (%lld)"; -"All unacknowledged (%lld)" = "Todas las no confirmadas (%lld)"; -"Mark alerts as seen" = "Marcar las alertas como vistas"; -"All rules, including seen" = "Todas las reglas, incluidas las vistas"; -"%@, including seen" = "%@, incluidas las vistas"; /* UI/AskView.swift */ "%@ · leaves this Mac" = "%@ · sale de este Mac"; @@ -752,8 +726,6 @@ "Seen as it happened, named, and readable." = "Visto en el momento, con nombre y legible."; /* UI/DevicesView.swift */ -"USB" = "USB"; -"Not watching" = "Sin vigilar"; "Read the device list again now (⌘R)" = "Volver a leer la lista de dispositivos ahora (⌘R)"; "Watch Bluetooth" = "Vigilar el Bluetooth"; "Watch USB and external storage" = "Vigilar el USB y el almacenamiento externo"; @@ -1234,13 +1206,8 @@ "Edit as normal rule…" = "Editar como regla normal…"; /* UI/RulesView.swift */ -"Packs" = "Paquetes"; -"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "Aquí aparecen las decisiones de las reglas y los bloqueos resumidos de los paquetes. La actividad de los paquetes se agrupa para que una página llena de anuncios no se convierta en una página llena de alertas."; "Managed packs" = "Paquetes gestionados"; -"What they did" = "Qué han hecho"; "Paused until %@" = "En pausa hasta %@"; -"%lld in force" = "%lld en vigor"; -"%1$lld of %2$lld in force" = "%1$lld de %2$lld en vigor"; "Templates" = "Plantillas"; "Rules worth having, grouped by what they are for, with the hosts already written out" = "Reglas que merece la pena tener, agrupadas por su propósito y con los hosts ya escritos"; "Write a rule: an app, an agent, a destination or a URL, blocked or allowed" = "Escribir una regla: una app, un agente, un destino o una URL, bloqueado o permitido"; @@ -1254,8 +1221,6 @@ "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld reglas se están vigilando, pero no se aplican. Solo la extensión de red está en el camino de los datos y puede rechazar una conexión; el muestreador nettop cuenta el tráfico a posteriori. Selecciona la extensión de red en Fuentes › Colector para aplicar estas reglas."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "Una regla se está vigilando, pero no se aplica: una regla que nombra una ruta solo puede compararse con la inspección HTTPS, y está desactivada."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld reglas se están vigilando, pero no se aplican: una regla que nombra una ruta solo puede compararse con la inspección HTTPS, y está desactivada."; -"In force" = "En vigor"; -"Not in force" = "Sin vigor"; "Nothing is being refused until %@." = "No se rechazará nada hasta %@."; "Resume now" = "Reanudar ahora"; "No rules yet" = "Todavía no hay reglas"; @@ -1265,7 +1230,6 @@ "Nothing yet" = "Todavía nada"; "Today" = "Hoy"; "Yesterday" = "Ayer"; -"A rule since deleted" = "Una regla ya eliminada"; "Edit…" = "Editar…"; "Reset Count" = "Poner el contador a cero"; "Delete" = "Eliminar"; @@ -1887,3 +1851,192 @@ "Restart or reinstall the filter to finish the update" = "Reinicia o reinstala el filtro para terminar la actualización"; "Return to the previous history scope" = "Volver al ámbito anterior del historial"; "Selected" = "Seleccionado"; + +/* 0.17.4 políticas y fuentes unificadas */ +"Policies" = "Políticas"; +"Activity" = "Actividad"; +"Choose the traffic source and verify that data is arriving." = "Elige la fuente de tráfico y comprueba que estén llegando datos."; +"Choose which local device channels Flowlight watches." = "Elige qué canales de dispositivos locales vigila Flowlight."; +"Check what Flowlight can name, attribute, and inspect." = "Comprueba qué puede nombrar, atribuir e inspeccionar Flowlight."; +"Network rules" = "Reglas de red"; +"Agent allowlists" = "Listas de permitidos de agentes"; +"Agent guardrails" = "Salvaguardas de agentes"; +"Request rewrites" = "Reescrituras de peticiones"; +"Response transforms" = "Transformaciones de respuestas"; +"In effect" = "En vigor"; +"Reporting only" = "Solo informa"; +"Waiting" = "En espera"; +"Unavailable" = "No disponible"; +"Not applying" = "Sin aplicar"; +"HTTPS inspection is off." = "La inspección HTTPS está desactivada."; +"HTTPS inspection is not running." = "La inspección HTTPS no está en marcha."; +"This policy is switched off." = "Esta política está desactivada."; +"This rule is switched off." = "Esta regla está desactivada."; +"Network blocking is paused." = "El bloqueo de red está en pausa."; +"This one-off rule has already been used." = "Esta regla de un solo uso ya se ha utilizado."; +"This rule is outside its scheduled time." = "Esta regla está fuera de su horario programado."; +"Every destination" = "Todos los destinos"; +"No listed destinations" = "Ningún destino en la lista"; +"1 listed destination" = "1 destino en la lista"; +"%lld listed destinations" = "%lld destinos en la lista"; +"This allowlist is switched off." = "Esta lista de permitidos está desactivada."; +"Unlisted destinations raise alerts but are not blocked." = "Los destinos que no estén en la lista generan alertas, pero no se bloquean."; +"Blocking is unavailable; unlisted destinations still raise alerts." = "El bloqueo no está disponible; los destinos que no estén en la lista siguen generando alertas."; +"Review the changed pack before it can keep applying." = "Revisa el paquete modificado para que pueda seguir aplicándose."; +"This pack is switched off." = "Este paquete está desactivado."; +"The pack is matching destinations but cannot block them here." = "El paquete detecta destinos coincidentes, pero aquí no puede bloquearlos."; +"The pack is waiting for the active filter to apply it." = "El paquete está esperando a que el filtro activo lo aplique."; +"The managed-pack engine is not applying this configuration." = "El motor de paquetes gestionados no está aplicando esta configuración."; +"No longer in the official catalog" = "Ya no figura en el catálogo oficial"; +"Blocking stopped automatically. Acknowledge this change after review." = "El bloqueo se detuvo automáticamente. Confirma este cambio después de revisarlo."; +"Every agent" = "Todos los agentes"; +"A policy since deleted" = "Una política ya eliminada"; +"%@ · %lld connections" = "%@ · %lld conexiones"; +"%lld policies · none currently effective" = "%lld políticas · ninguna en vigor ahora"; +"%1$lld policies · %2$lld currently effective" = "%1$lld políticas · %2$lld en vigor ahora"; +"Everything Flowlight can apply" = "Todo lo que Flowlight puede aplicar"; +"Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent." = "Aquí se reúnen las decisiones de red, los límites de agentes, las listas de destinos gestionadas y las políticas de inspección para desarrolladores. Cada política sigue abriéndose en el editor que controla su comportamiento y su consentimiento."; +"Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent." = "Aquí se reúnen las decisiones de red, los límites de agentes y las listas de destinos gestionadas. Cada política sigue abriéndose en el editor que controla su comportamiento y su consentimiento."; +"%lld destinations · %@" = "%lld destinos · %@"; +"every app" = "todas las apps"; +"Official · pack v%@ · %@" = "Oficial · paquete v%@ · %@"; +"Flowlight · signer %@" = "Flowlight · firmante %@"; +"Catalog" = "Catálogo"; +"Sequence %1$lld · digest %2$@" = "Secuencia %1$lld · resumen %2$@"; +"Last verified" = "Última verificación"; +"Next check" = "Próxima comprobación"; +"Official catalog details" = "Detalles del catálogo oficial"; +"Not checked" = "Sin comprobar"; +"Verified" = "Verificado"; +"Stale" = "Obsoleto"; +"Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records." = "Aquí aparecen bloqueos, excepciones, incumplimientos de listas de permitidos, salvaguardas, simulaciones y cambios en peticiones o respuestas sin alterar sus registros originales."; +"Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records." = "Aquí aparecen bloqueos de red, excepciones, coincidencias de paquetes gestionados e incumplimientos de listas de permitidos sin alterar sus registros originales."; +"Blocked connection or request" = "Conexión o petición bloqueada"; +"Blocked by a managed pack" = "Bloqueado por un paquete gestionado"; +"Outside an agent allowlist" = "Fuera de la lista de permitidos de un agente"; +"Capability removed by a guardrail" = "Capacidad retirada por una salvaguarda"; +"Answered by a mock" = "Respondido por una simulación"; +"Request changed before sending" = "Petición modificada antes de enviarla"; +"Response changed before delivery" = "Respuesta modificada antes de entregarla"; + +/* 0.17.5 perfiles de proveedor, estado de agentes y triaje de alertas */ +"Couldn't update alerts: %@" = "No se pudieron actualizar las alertas: %@"; +"Needs attention" = "Requiere atención"; +"Ready" = "Listo"; +"This provider profile is disabled." = "Este perfil de proveedor está desactivado."; +"Assign at least one inspection plugin." = "Asigna al menos un plugin de inspección."; +"An assigned plugin is no longer installed." = "Un plugin asignado ya no está instalado."; +"An assigned plugin uses a different provider." = "Un plugin asignado utiliza otro proveedor."; +"Every assigned plugin is disabled." = "Todos los plugins asignados están desactivados."; +"1 inspection plugin assigned." = "1 plugin de inspección asignado."; +"%lld inspection plugins assigned." = "%lld plugins de inspección asignados."; +"Enter a provider profile name between 1 and 80 characters." = "Introduce un nombre de perfil de proveedor de entre 1 y 80 caracteres."; +"Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed." = "Usa un punto de conexión HTTPS, o HTTP solo para localhost. No se permiten la información de usuario, los parámetros de consulta ni los fragmentos."; +"Use a Keychain reference such as keychain:service/account, not a credential value." = "Usa una referencia del llavero como keychain:service/account, no un valor de credencial."; +"Provider options must be short, non-secret key/value metadata." = "Las opciones del proveedor deben ser metadatos breves de clave y valor y no contener secretos."; +"An assigned plugin identifier is invalid." = "El identificador de un plugin asignado no es válido."; +"Provider profiles could not be loaded. Create them again in Inspect." = "No se pudieron cargar los perfiles de proveedor. Vuelve a crearlos en Inspeccionar."; +"Healthy" = "Correcto"; +"Needs setup" = "Requiere configuración"; +"Limited visibility" = "Visibilidad limitada"; +"Attention" = "Atención"; +"Not observed" = "No observado"; +"Detected agent" = "Agente detectado"; +"Traffic was observed in the selected window." = "Se observó tráfico en el periodo seleccionado."; +"Configuration was found, but no traffic was observed in the selected window." = "Se encontró una configuración, pero no se observó tráfico en el periodo seleccionado."; +"Installed on this Mac, with no traffic in the selected window." = "Instalado en este Mac, sin tráfico en el periodo seleccionado."; +"No traffic was observed in the selected window." = "No se observó tráfico en el periodo seleccionado."; +"Always Monitor is configured, but no current route was observed." = "Always Monitor está configurado, pero no se observó ninguna ruta actual."; +"Its route through Flowlight was observed." = "Se observó su ruta a través de Flowlight."; +"The saved Flowlight route was not observed in this window." = "La ruta de Flowlight guardada no se observó en este periodo."; +"Flowlight could not establish the requested route." = "Flowlight no pudo establecer la ruta solicitada."; +"HTTPS inspection is unavailable in this build; only connection metadata is visible." = "La inspección HTTPS no está disponible en esta versión; solo se ven los metadatos de conexión."; +"HTTPS inspection is off; request contents are not visible." = "La inspección HTTPS está desactivada; el contenido de las peticiones no es visible."; +"The HTTPS inspection certificate is not trusted." = "El certificado de inspección HTTPS no es de confianza."; +"HTTPS inspection is enabled, but its proxy is not ready." = "La inspección HTTPS está activada, pero su proxy no está listo."; +"No inspected requests were observed in this window." = "No se observaron peticiones inspeccionadas en este periodo."; +"HTTPS inspection observed this agent's traffic." = "La inspección HTTPS observó el tráfico de este agente."; +"One observed destination is outside its policy." = "Un destino observado está fuera de su política."; +"%lld observed destinations are outside its policy." = "%lld destinos observados están fuera de su política."; +"Its enforcing destination policy covers the observed traffic." = "Su política de destinos aplicada cubre el tráfico observado."; +"Its reporting destination policy covers the observed traffic." = "Su política de destinos informativa cubre el tráfico observado."; +"No agent destination policy is enabled." = "No hay ninguna política de destinos de agentes activada."; +"One unresolved alert needs review." = "Una alerta sin resolver requiere revisión."; +"%lld unresolved alerts need review." = "%lld alertas sin resolver requieren revisión."; +"Installed, configured, and observed AI agents, with explainable setup and visibility health." = "Agentes de IA instalados, configurados y observados, con información clara sobre el estado de su configuración y visibilidad."; +"No AI agents found" = "No se encontraron agentes de IA"; +"Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider." = "Flowlight inventaría apps compatibles y agentes de línea de comandos configurados, y descubre clientes que no son navegadores y contactan con un proveedor de IA conocido."; +"Agents inventoried" = "Agentes inventariados"; +"Active in window" = "Activos en el periodo"; +"Health" = "Estado"; +"Why" = "Por qué"; +"Policy" = "Política"; +"Not set" = "Sin configurar"; +"Enforcing" = "En vigor"; +"Reporting" = "Solo informa"; +"No traffic detail is available for this agent in the selected window." = "No hay detalles de tráfico disponibles para este agente en el periodo seleccionado."; +"No matching alerts" = "No hay alertas que coincidan"; +"Nothing matches this rule" = "Nada coincide con esta regla"; +"State" = "Estado"; +"Disposition" = "Clasificación"; +"Acknowledge all new (%lld)" = "Confirmar todas las nuevas (%lld)"; +"Triage" = "Triaje"; +"Update the selected alerts" = "Actualizar las alertas seleccionadas"; +"Set State" = "Establecer estado"; +"Set Disposition" = "Establecer clasificación"; +"Edit Note…" = "Editar nota…"; +"Triage Note" = "Nota de triaje"; +"%lld open, no new" = "%lld abiertas, ninguna nueva"; +"%lld new of %lld open" = "%lld nuevas de %lld abiertas"; +"%lld investigating" = "%lld en investigación"; +"%lld resolved" = "%lld resueltas"; +"%lld dismissed" = "%lld descartadas"; +"No open alerts match the current Focus scope." = "Ninguna alerta abierta coincide con el ámbito de Foco actual."; +"No alerts are being investigated in the current Focus scope." = "No se está investigando ninguna alerta en el ámbito de Foco actual."; +"No resolved alerts match the current Focus scope." = "Ninguna alerta resuelta coincide con el ámbito de Foco actual."; +"No dismissed alerts match the current Focus scope." = "Ninguna alerta descartada coincide con el ámbito de Foco actual."; +"New" = "Nueva"; +"Acknowledged" = "Confirmada"; +"Investigating" = "En investigación"; +"Resolved" = "Resuelta"; +"Dismissed" = "Descartada"; +"Unset" = "Sin definir"; +"True positive" = "Verdadero positivo"; +"Expected / benign" = "Esperado / benigno"; +"False positive" = "Falso positivo"; +"Open" = "Abierta"; +"Plugins & Providers" = "Plugins y proveedores"; +"Manage inspection plugins and local provider profiles" = "Gestiona los plugins de inspección y los perfiles de proveedores locales"; +"Live network traffic" = "Tráfico de red en directo"; +"No time selected" = "No hay ninguna hora seleccionada"; +"Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second." = "Los círculos sólidos son descargas; los rombos discontinuos son subidas. Usa las teclas de flecha izquierda y derecha para examinar cada segundo."; +"Download · solid" = "Descarga · círculo sólido"; +"Upload · dashed" = "Subida · rombo discontinuo"; +"Inspection extensions, local routing metadata, and retained findings." = "Extensiones de inspección, metadatos de enrutamiento local y hallazgos conservados."; +"No provider profiles" = "No hay perfiles de proveedor"; +"Profiles group local provider settings with the inspection plugins that use them." = "Los perfiles agrupan los ajustes locales del proveedor con los plugins de inspección que los utilizan."; +"Add Provider…" = "Añadir proveedor…"; +"Provider profiles" = "Perfiles de proveedor"; +"Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile." = "Los perfiles permanecen en este Mac. Contienen opciones sin secretos y referencias opcionales del llavero; Flowlight nunca guarda valores de credenciales en un perfil."; +"Saved provider profile %@." = "Perfil de proveedor %@ guardado."; +"Remove provider profile?" = "¿Quitar el perfil de proveedor?"; +"Removed provider profile %@." = "Perfil de proveedor %@ eliminado."; +"Could not remove provider profile." = "No se pudo eliminar el perfil de proveedor."; +"Plugins and their retained findings stay installed." = "Los plugins y sus hallazgos conservados permanecen instalados."; +"Credential value kept in Keychain" = "Valor de credencial guardado en el llavero"; +"Add Provider Profile" = "Añadir perfil de proveedor"; +"Edit Provider Profile" = "Editar perfil de proveedor"; +"Identity" = "Identidad"; +"Enabled" = "Activado"; +"Endpoint (optional)" = "Punto de conexión (opcional)"; +"Keychain reference (optional)" = "Referencia del llavero (opcional)"; +"Use keychain:service/account. The credential value is never stored in this profile." = "Usa keychain:service/account. El valor de la credencial nunca se guarda en este perfil."; +"No installed plugins use this provider." = "Ningún plugin instalado usa este proveedor."; +"Plugin enabled" = "Plugin activado"; +"Plugin disabled" = "Plugin desactivado"; +"Inspection plugins" = "Plugins de inspección"; +"Option" = "Opción"; +"Remove provider option" = "Quitar opción del proveedor"; +"Add Option" = "Añadir opción"; +"Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected." = "Las opciones son metadatos locales sin secretos. Se rechazan las claves de API, los tokens, las contraseñas y otras credenciales."; +"Provider options" = "Opciones del proveedor"; diff --git a/Flowlight/Localization/fr.lproj/Localizable.strings b/Flowlight/Localization/fr.lproj/Localizable.strings index 77d49d96..a431470f 100644 --- a/Flowlight/Localization/fr.lproj/Localizable.strings +++ b/Flowlight/Localization/fr.lproj/Localizable.strings @@ -372,7 +372,6 @@ /* Inspection/InspectionController.swift */ "AI agents and their tools" = "Agents IA et leurs outils"; "Every app that uses the proxy" = "Toutes les apps qui utilisent le proxy"; -"Removed %@" = "%@ supprimé"; "No network services to send through the proxy, so nothing would be inspected. Check System Settings › Network." = "Aucun service réseau à faire passer par le proxy : rien ne serait inspecté. Vérifier Réglages Système › Réseau."; "authorization failed" = "échec de l'autorisation"; "Couldn't set Flowlight up: %@" = "Impossible de configurer Flowlight : %@"; @@ -423,24 +422,11 @@ "Last hour" = "Dernière heure"; "24 hours" = "24 heures"; "7 days" = "7 jours"; -"Observed AI agent activity, including destinations outside each agent's model provider." = "Activité observée des agents IA, y compris les destinations extérieures au fournisseur de modèle de chaque agent."; "Window" = "Période"; -"No AI agents seen" = "Aucun agent IA observé"; -"Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider." = "Flowlight reconnaît les agents pris en charge comme Claude Code, Codex, Cursor et Ollama. Il classe aussi les apps hors navigateur qui contactent un fournisseur d'API LLM connu."; -"Agents active" = "Agents actifs"; -"AI API traffic" = "Trafic d'API IA"; -"Uploaded to other hosts" = "Envoyé vers d'autres hôtes"; -"Using sensitive channels" = "Canaux sensibles utilisés"; -"Agent alerts" = "Alertes d'agents"; "Agent" = "Agent"; "Detected: calls an AI API" = "Détecté : appelle une API IA"; "AI providers" = "Fournisseurs IA"; -"AI traffic" = "Trafic IA"; -"Other hosts" = "Autres hôtes"; -"Sent elsewhere" = "Envoyé ailleurs"; -"%@ of this agent's uploads went to non-AI hosts" = "%@ des envois de cet agent sont allés vers des hôtes non IA"; "Allowlist" = "Liste d'autorisation"; -"Risk" = "Risque"; "Show Traffic in History" = "Voir le trafic dans l’historique"; "None seen" = "Aucun observé"; "Large upload" = "Envoi volumineux"; @@ -558,12 +544,6 @@ "No %@ in this window. Flowlight is decrypting %@'s traffic; its model just hasn't asked for any." = "Aucun %@ sur cette période. Flowlight déchiffre le trafic de %@ ; son modèle n'en a simplement pas demandé."; /* UI/AlertsView.swift */ -"No alerts" = "Aucune alerte"; -"Nothing to review" = "Rien à examiner"; -"Each app has a learning period (24 h by default) before first-contact and spike rules fire." = "Chaque app dispose d'une période d'apprentissage (24 h par défaut) avant que les règles de premier contact et de pic ne se déclenchent."; -"All matching alerts have been acknowledged." = "Toutes les alertes correspondantes ont été acquittées."; -"%lld alerts, all acknowledged" = "%lld alertes, toutes acquittées"; -"%lld to review of %lld" = "%lld à examiner sur %lld"; "Critical" = "Critique"; "Warning" = "Avertissement"; "Info" = "Info"; @@ -580,13 +560,7 @@ "Acknowledge" = "Acquitter"; "Copy Detail" = "Copier le détail"; "All rules" = "Toutes les règles"; -"Show acknowledged" = "Afficher les alertes acquittées"; "Filter which alerts are listed" = "Filtrer les alertes affichées"; -"Selected (%lld)" = "Sélectionnées (%lld)"; -"All unacknowledged (%lld)" = "Toutes les non acquittées (%lld)"; -"Mark alerts as seen" = "Marquer les alertes comme vues"; -"All rules, including seen" = "Toutes les règles, y compris vues"; -"%@, including seen" = "%@, y compris vues"; /* UI/AskView.swift */ "%@ · leaves this Mac" = "%@ · quitte ce Mac"; @@ -752,8 +726,6 @@ "Seen as it happened, named, and readable." = "Vu au moment où cela s'est produit, nommé et lisible."; /* UI/DevicesView.swift */ -"USB" = "USB"; -"Not watching" = "Non surveillé"; "Read the device list again now (⌘R)" = "Relire la liste des appareils maintenant (⌘R)"; "Watch Bluetooth" = "Surveiller le Bluetooth"; "Watch USB and external storage" = "Surveiller l'USB et le stockage externe"; @@ -1234,13 +1206,8 @@ "Edit as normal rule…" = "Modifier comme règle normale…"; /* UI/RulesView.swift */ -"Packs" = "Packs"; -"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "Les décisions des règles et les blocages résumés des packs apparaissent ici. L’activité des packs est regroupée afin qu’une page remplie de publicités ne devienne pas une page remplie d’alertes."; "Managed packs" = "Packs gérés"; -"What they did" = "Ce qu'elles ont fait"; "Paused until %@" = "Suspendu jusqu'à %@"; -"%lld in force" = "%lld en vigueur"; -"%1$lld of %2$lld in force" = "%1$lld sur %2$lld en vigueur"; "Templates" = "Modèles"; "Rules worth having, grouped by what they are for, with the hosts already written out" = "Des règles utiles, groupées par usage, avec les hôtes déjà renseignés"; "Write a rule: an app, an agent, a destination or a URL, blocked or allowed" = "Écrire une règle : une app, un agent, une destination ou une URL, bloquée ou autorisée"; @@ -1254,8 +1221,6 @@ "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld règles sont surveillées mais pas appliquées. Seule l'extension réseau se trouve sur le trajet des données et peut refuser une connexion ; l'échantillonneur nettop compte le trafic après coup. Sélectionner l'extension réseau dans Sources › Collecteur pour appliquer ces règles."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "Une règle est surveillée mais pas appliquée — une règle qui nomme un chemin ne peut être mise en correspondance que par l'inspection HTTPS, et celle-ci est désactivée."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld règles sont surveillées mais pas appliquées — une règle qui nomme un chemin ne peut être mise en correspondance que par l'inspection HTTPS, et celle-ci est désactivée."; -"In force" = "En vigueur"; -"Not in force" = "Pas en vigueur"; "Nothing is being refused until %@." = "Rien n'est refusé jusqu'à %@."; "Resume now" = "Reprendre maintenant"; "No rules yet" = "Aucune règle pour l'instant"; @@ -1265,7 +1230,6 @@ "Nothing yet" = "Rien pour l'instant"; "Today" = "Aujourd'hui"; "Yesterday" = "Hier"; -"A rule since deleted" = "Une règle depuis supprimée"; "Edit…" = "Modifier…"; "Reset Count" = "Réinitialiser le compteur"; "Delete" = "Supprimer"; @@ -1887,3 +1851,192 @@ "Restart or reinstall the filter to finish the update" = "Redémarrer ou réinstaller le filtre pour terminer la mise à jour"; "Return to the previous history scope" = "Revenir à la plage précédente de l'historique"; "Selected" = "Sélectionné"; + +/* 0.17.4 politiques et sources unifiées */ +"Policies" = "Politiques"; +"Activity" = "Activité"; +"Choose the traffic source and verify that data is arriving." = "Choisissez la source de trafic et vérifiez que les données arrivent."; +"Choose which local device channels Flowlight watches." = "Choisissez les canaux d’appareils locaux que Flowlight surveille."; +"Check what Flowlight can name, attribute, and inspect." = "Vérifiez ce que Flowlight peut nommer, attribuer et inspecter."; +"Network rules" = "Règles réseau"; +"Agent allowlists" = "Listes d’autorisation des agents"; +"Agent guardrails" = "Garde-fous des agents"; +"Request rewrites" = "Réécritures de requêtes"; +"Response transforms" = "Transformations de réponses"; +"In effect" = "En vigueur"; +"Reporting only" = "Signalement uniquement"; +"Waiting" = "En attente"; +"Unavailable" = "Indisponible"; +"Not applying" = "Non appliqué"; +"HTTPS inspection is off." = "L’inspection HTTPS est désactivée."; +"HTTPS inspection is not running." = "L’inspection HTTPS ne fonctionne pas."; +"This policy is switched off." = "Cette politique est désactivée."; +"This rule is switched off." = "Cette règle est désactivée."; +"Network blocking is paused." = "Le blocage réseau est suspendu."; +"This one-off rule has already been used." = "Cette règle à usage unique a déjà été utilisée."; +"This rule is outside its scheduled time." = "Cette règle est en dehors de sa plage horaire."; +"Every destination" = "Toutes les destinations"; +"No listed destinations" = "Aucune destination listée"; +"1 listed destination" = "1 destination listée"; +"%lld listed destinations" = "%lld destinations listées"; +"This allowlist is switched off." = "Cette liste d’autorisation est désactivée."; +"Unlisted destinations raise alerts but are not blocked." = "Les destinations non listées déclenchent des alertes, mais ne sont pas bloquées."; +"Blocking is unavailable; unlisted destinations still raise alerts." = "Le blocage est indisponible ; les destinations non listées déclenchent toujours des alertes."; +"Review the changed pack before it can keep applying." = "Examinez le pack modifié avant qu’il puisse continuer à s’appliquer."; +"This pack is switched off." = "Ce pack est désactivé."; +"The pack is matching destinations but cannot block them here." = "Le pack reconnaît des destinations, mais ne peut pas les bloquer ici."; +"The pack is waiting for the active filter to apply it." = "Le pack attend que le filtre actif l’applique."; +"The managed-pack engine is not applying this configuration." = "Le moteur des packs gérés n’applique pas cette configuration."; +"No longer in the official catalog" = "Ne figure plus dans le catalogue officiel"; +"Blocking stopped automatically. Acknowledge this change after review." = "Le blocage s’est arrêté automatiquement. Confirmez ce changement après vérification."; +"Every agent" = "Tous les agents"; +"A policy since deleted" = "Une politique désormais supprimée"; +"%@ · %lld connections" = "%@ · %lld connexions"; +"%lld policies · none currently effective" = "%lld politiques · aucune actuellement en vigueur"; +"%1$lld policies · %2$lld currently effective" = "%1$lld politiques · %2$lld actuellement en vigueur"; +"Everything Flowlight can apply" = "Tout ce que Flowlight peut appliquer"; +"Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent." = "Les décisions réseau, les limites des agents, les listes de destinations gérées et les politiques d’inspection pour développeurs sont réunies ici. Chaque politique s’ouvre toujours dans l’éditeur qui gère son comportement et son consentement."; +"Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent." = "Les décisions réseau, les limites des agents et les listes de destinations gérées sont réunies ici. Chaque politique s’ouvre toujours dans l’éditeur qui gère son comportement et son consentement."; +"%lld destinations · %@" = "%lld destinations · %@"; +"every app" = "toutes les apps"; +"Official · pack v%@ · %@" = "Officiel · pack v%@ · %@"; +"Flowlight · signer %@" = "Flowlight · signataire %@"; +"Catalog" = "Catalogue"; +"Sequence %1$lld · digest %2$@" = "Séquence %1$lld · empreinte %2$@"; +"Last verified" = "Dernière vérification"; +"Next check" = "Prochaine vérification"; +"Official catalog details" = "Détails du catalogue officiel"; +"Not checked" = "Non vérifié"; +"Verified" = "Vérifié"; +"Stale" = "Obsolète"; +"Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records." = "Les blocages, exceptions, violations de listes d’autorisation, garde-fous, simulations et modifications de requêtes ou de réponses apparaissent ici sans altérer leurs enregistrements d’origine."; +"Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records." = "Les blocages réseau, exceptions, correspondances de packs gérés et violations de listes d’autorisation apparaissent ici sans altérer leurs enregistrements d’origine."; +"Blocked connection or request" = "Connexion ou requête bloquée"; +"Blocked by a managed pack" = "Bloqué par un pack géré"; +"Outside an agent allowlist" = "Hors de la liste d’autorisation d’un agent"; +"Capability removed by a guardrail" = "Capacité retirée par un garde-fou"; +"Answered by a mock" = "Traité par une simulation"; +"Request changed before sending" = "Requête modifiée avant l’envoi"; +"Response changed before delivery" = "Réponse modifiée avant la remise"; + +/* 0.17.5 profils de fournisseurs, état des agents et triage des alertes */ +"Couldn't update alerts: %@" = "Impossible de mettre à jour les alertes : %@"; +"Needs attention" = "Attention requise"; +"Ready" = "Prêt"; +"This provider profile is disabled." = "Ce profil de fournisseur est désactivé."; +"Assign at least one inspection plugin." = "Affectez au moins un plugin d’inspection."; +"An assigned plugin is no longer installed." = "Un plugin affecté n’est plus installé."; +"An assigned plugin uses a different provider." = "Un plugin affecté utilise un autre fournisseur."; +"Every assigned plugin is disabled." = "Tous les plugins affectés sont désactivés."; +"1 inspection plugin assigned." = "1 plugin d’inspection affecté."; +"%lld inspection plugins assigned." = "%lld plugins d’inspection affectés."; +"Enter a provider profile name between 1 and 80 characters." = "Saisissez un nom de profil de fournisseur de 1 à 80 caractères."; +"Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed." = "Utilisez un point de terminaison HTTPS, ou HTTP uniquement pour localhost. Les informations utilisateur, les paramètres de requête et les fragments ne sont pas autorisés."; +"Use a Keychain reference such as keychain:service/account, not a credential value." = "Utilisez une référence au trousseau telle que keychain:service/account, et non une donnée d’authentification."; +"Provider options must be short, non-secret key/value metadata." = "Les options du fournisseur doivent être de courtes métadonnées clé-valeur non secrètes."; +"An assigned plugin identifier is invalid." = "L’identifiant d’un plugin affecté n’est pas valide."; +"Provider profiles could not be loaded. Create them again in Inspect." = "Les profils de fournisseurs n’ont pas pu être chargés. Recréez-les dans Inspecter."; +"Healthy" = "Opérationnel"; +"Needs setup" = "Configuration requise"; +"Limited visibility" = "Visibilité limitée"; +"Attention" = "Attention"; +"Not observed" = "Non observé"; +"Detected agent" = "Agent détecté"; +"Traffic was observed in the selected window." = "Du trafic a été observé sur la période sélectionnée."; +"Configuration was found, but no traffic was observed in the selected window." = "Une configuration a été trouvée, mais aucun trafic n’a été observé sur la période sélectionnée."; +"Installed on this Mac, with no traffic in the selected window." = "Installé sur ce Mac, sans trafic sur la période sélectionnée."; +"No traffic was observed in the selected window." = "Aucun trafic n’a été observé sur la période sélectionnée."; +"Always Monitor is configured, but no current route was observed." = "Always Monitor est configuré, mais aucun acheminement actuel n’a été observé."; +"Its route through Flowlight was observed." = "Son acheminement via Flowlight a été observé."; +"The saved Flowlight route was not observed in this window." = "L’acheminement enregistré via Flowlight n’a pas été observé sur cette période."; +"Flowlight could not establish the requested route." = "Flowlight n’a pas pu établir l’acheminement demandé."; +"HTTPS inspection is unavailable in this build; only connection metadata is visible." = "L’inspection HTTPS n’est pas disponible dans cette version ; seules les métadonnées de connexion sont visibles."; +"HTTPS inspection is off; request contents are not visible." = "L’inspection HTTPS est désactivée ; le contenu des requêtes n’est pas visible."; +"The HTTPS inspection certificate is not trusted." = "Le certificat d’inspection HTTPS n’est pas approuvé."; +"HTTPS inspection is enabled, but its proxy is not ready." = "L’inspection HTTPS est activée, mais son proxy n’est pas prêt."; +"No inspected requests were observed in this window." = "Aucune requête inspectée n’a été observée sur cette période."; +"HTTPS inspection observed this agent's traffic." = "L’inspection HTTPS a observé le trafic de cet agent."; +"One observed destination is outside its policy." = "Une destination observée est en dehors de sa politique."; +"%lld observed destinations are outside its policy." = "%lld destinations observées sont en dehors de sa politique."; +"Its enforcing destination policy covers the observed traffic." = "Sa politique de destination en vigueur couvre le trafic observé."; +"Its reporting destination policy covers the observed traffic." = "Sa politique de destination de signalement couvre le trafic observé."; +"No agent destination policy is enabled." = "Aucune politique de destination d’agent n’est activée."; +"One unresolved alert needs review." = "Une alerte non résolue doit être examinée."; +"%lld unresolved alerts need review." = "%lld alertes non résolues doivent être examinées."; +"Installed, configured, and observed AI agents, with explainable setup and visibility health." = "Agents IA installés, configurés et observés, avec un état explicite de leur configuration et de leur visibilité."; +"No AI agents found" = "Aucun agent IA trouvé"; +"Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider." = "Flowlight inventorie les apps prises en charge et les agents en ligne de commande configurés, et découvre les clients hors navigateur qui contactent un fournisseur IA connu."; +"Agents inventoried" = "Agents inventoriés"; +"Active in window" = "Actifs sur la période"; +"Health" = "État"; +"Why" = "Pourquoi"; +"Policy" = "Politique"; +"Not set" = "Non défini"; +"Enforcing" = "En vigueur"; +"Reporting" = "Signalement uniquement"; +"No traffic detail is available for this agent in the selected window." = "Aucun détail de trafic n’est disponible pour cet agent sur la période sélectionnée."; +"No matching alerts" = "Aucune alerte correspondante"; +"Nothing matches this rule" = "Rien ne correspond à cette règle"; +"State" = "État"; +"Disposition" = "Qualification"; +"Acknowledge all new (%lld)" = "Acquitter toutes les nouvelles (%lld)"; +"Triage" = "Triage"; +"Update the selected alerts" = "Mettre à jour les alertes sélectionnées"; +"Set State" = "Définir l’état"; +"Set Disposition" = "Définir la qualification"; +"Edit Note…" = "Modifier la note…"; +"Triage Note" = "Note de triage"; +"%lld open, no new" = "%lld ouvertes, aucune nouvelle"; +"%lld new of %lld open" = "%lld nouvelles sur %lld ouvertes"; +"%lld investigating" = "%lld en cours d’investigation"; +"%lld resolved" = "%lld résolues"; +"%lld dismissed" = "%lld rejetées"; +"No open alerts match the current Focus scope." = "Aucune alerte ouverte ne correspond à la portée Focus actuelle."; +"No alerts are being investigated in the current Focus scope." = "Aucune alerte n’est en cours d’investigation dans la portée Focus actuelle."; +"No resolved alerts match the current Focus scope." = "Aucune alerte résolue ne correspond à la portée Focus actuelle."; +"No dismissed alerts match the current Focus scope." = "Aucune alerte rejetée ne correspond à la portée Focus actuelle."; +"New" = "Nouvelle"; +"Acknowledged" = "Acquittée"; +"Investigating" = "En cours d’investigation"; +"Resolved" = "Résolue"; +"Dismissed" = "Rejetée"; +"Unset" = "Non défini"; +"True positive" = "Vrai positif"; +"Expected / benign" = "Attendu / bénin"; +"False positive" = "Faux positif"; +"Open" = "Ouverte"; +"Plugins & Providers" = "Plugins et fournisseurs"; +"Manage inspection plugins and local provider profiles" = "Gérer les plugins d’inspection et les profils de fournisseurs locaux"; +"Live network traffic" = "Trafic réseau en direct"; +"No time selected" = "Aucun instant sélectionné"; +"Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second." = "Les cercles pleins représentent les téléchargements ; les losanges en pointillés représentent les envois. Utilisez les touches Flèche gauche et Flèche droite pour inspecter chaque seconde."; +"Download · solid" = "Téléchargement · cercle plein"; +"Upload · dashed" = "Envoi · losange pointillé"; +"Inspection extensions, local routing metadata, and retained findings." = "Extensions d’inspection, métadonnées d’acheminement local et constats conservés."; +"No provider profiles" = "Aucun profil de fournisseur"; +"Profiles group local provider settings with the inspection plugins that use them." = "Les profils regroupent les réglages locaux du fournisseur avec les plugins d’inspection qui les utilisent."; +"Add Provider…" = "Ajouter un fournisseur…"; +"Provider profiles" = "Profils de fournisseurs"; +"Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile." = "Les profils restent sur ce Mac. Ils contiennent des options non secrètes et des références facultatives au trousseau ; Flowlight ne stocke jamais de données d’authentification dans un profil."; +"Saved provider profile %@." = "Profil de fournisseur %@ enregistré."; +"Remove provider profile?" = "Supprimer le profil de fournisseur ?"; +"Removed provider profile %@." = "Profil de fournisseur %@ supprimé."; +"Could not remove provider profile." = "Impossible de supprimer le profil de fournisseur."; +"Plugins and their retained findings stay installed." = "Les plugins et leurs constats conservés restent installés."; +"Credential value kept in Keychain" = "Donnée d’authentification conservée dans le trousseau"; +"Add Provider Profile" = "Ajouter un profil de fournisseur"; +"Edit Provider Profile" = "Modifier le profil de fournisseur"; +"Identity" = "Identité"; +"Enabled" = "Activé"; +"Endpoint (optional)" = "Point de terminaison (facultatif)"; +"Keychain reference (optional)" = "Référence au trousseau (facultative)"; +"Use keychain:service/account. The credential value is never stored in this profile." = "Utilisez keychain:service/account. La donnée d’authentification n’est jamais stockée dans ce profil."; +"No installed plugins use this provider." = "Aucun plugin installé n’utilise ce fournisseur."; +"Plugin enabled" = "Plugin activé"; +"Plugin disabled" = "Plugin désactivé"; +"Inspection plugins" = "Plugins d’inspection"; +"Option" = "Option"; +"Remove provider option" = "Supprimer l’option du fournisseur"; +"Add Option" = "Ajouter une option"; +"Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected." = "Les options sont des métadonnées locales non secrètes. Les clés API, les jetons, les mots de passe et les autres données d’identification sont refusés."; +"Provider options" = "Options du fournisseur"; diff --git a/Flowlight/Localization/it.lproj/Localizable.strings b/Flowlight/Localization/it.lproj/Localizable.strings index eebba212..6eef28bd 100644 --- a/Flowlight/Localization/it.lproj/Localizable.strings +++ b/Flowlight/Localization/it.lproj/Localizable.strings @@ -372,7 +372,6 @@ /* Inspection/InspectionController.swift */ "AI agents and their tools" = "Agenti IA e i loro strumenti"; "Every app that uses the proxy" = "Tutte le app che usano il proxy"; -"Removed %@" = "%@ rimosso"; "No network services to send through the proxy, so nothing would be inspected. Check System Settings › Network." = "Nessun servizio di rete da instradare attraverso il proxy, quindi non verrebbe ispezionato nulla. Controlla Impostazioni di Sistema › Rete."; "authorization failed" = "autorizzazione non riuscita"; "Couldn't set Flowlight up: %@" = "Impossibile configurare Flowlight: %@"; @@ -423,24 +422,11 @@ "Last hour" = "Ultima ora"; "24 hours" = "24 ore"; "7 days" = "7 giorni"; -"Observed AI agent activity, including destinations outside each agent's model provider." = "Attività osservata degli agenti IA, comprese le destinazioni esterne al provider del modello di ciascun agente."; "Window" = "Intervallo"; -"No AI agents seen" = "Nessun agente IA rilevato"; -"Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider." = "Flowlight riconosce gli agenti supportati come Claude Code, Codex, Cursor e Ollama. Classifica inoltre le app non browser che contattano un provider di API LLM noto."; -"Agents active" = "Agenti attivi"; -"AI API traffic" = "Traffico API IA"; -"Uploaded to other hosts" = "Caricati su altri host"; -"Using sensitive channels" = "Uso di canali sensibili"; -"Agent alerts" = "Avvisi sugli agenti"; "Agent" = "Agente"; "Detected: calls an AI API" = "Rilevato: chiama un'API IA"; "AI providers" = "Provider IA"; -"AI traffic" = "Traffico IA"; -"Other hosts" = "Altri host"; -"Sent elsewhere" = "Inviati altrove"; -"%@ of this agent's uploads went to non-AI hosts" = "%@ dei caricamenti di questo agente è andato verso host non IA"; "Allowlist" = "Elenco consentiti"; -"Risk" = "Rischio"; "Show Traffic in History" = "Mostra il traffico in Cronologia"; "None seen" = "Nessuno rilevato"; "Large upload" = "Caricamento ingente"; @@ -558,12 +544,6 @@ "No %@ in this window. Flowlight is decrypting %@'s traffic; its model just hasn't asked for any." = "Nulla da mostrare per %@ in questo intervallo. Flowlight sta decifrando il traffico di %@; semplicemente il suo modello non ne ha fatto uso."; /* UI/AlertsView.swift */ -"No alerts" = "Nessun avviso"; -"Nothing to review" = "Nulla da esaminare"; -"Each app has a learning period (24 h by default) before first-contact and spike rules fire." = "Ogni app ha un periodo di apprendimento (24 h per impostazione predefinita) prima che scattino le regole di primo contatto e di picco."; -"All matching alerts have been acknowledged." = "Tutti gli avvisi corrispondenti sono stati segnati come visti."; -"%lld alerts, all acknowledged" = "%lld avvisi, tutti visti"; -"%lld to review of %lld" = "%lld da esaminare su %lld"; "Critical" = "Critico"; "Warning" = "Avvertenza"; "Info" = "Informazione"; @@ -580,13 +560,7 @@ "Acknowledge" = "Segna come visto"; "Copy Detail" = "Copia i dettagli"; "All rules" = "Tutte le regole"; -"Show acknowledged" = "Mostra quelli visti"; "Filter which alerts are listed" = "Filtra gli avvisi elencati"; -"Selected (%lld)" = "Selezionati (%lld)"; -"All unacknowledged (%lld)" = "Tutti quelli non visti (%lld)"; -"Mark alerts as seen" = "Segna gli avvisi come visti"; -"All rules, including seen" = "Tutte le regole, compresi i visti"; -"%@, including seen" = "%@, compresi i visti"; /* UI/AskView.swift */ "%@ · leaves this Mac" = "%@ · lascia questo Mac"; @@ -752,8 +726,6 @@ "Seen as it happened, named, and readable." = "Visto mentre accadeva, con un nome e leggibile."; /* UI/DevicesView.swift */ -"USB" = "USB"; -"Not watching" = "Non monitorato"; "Read the device list again now (⌘R)" = "Rileggi subito l'elenco dei dispositivi (⌘R)"; "Watch Bluetooth" = "Monitora il Bluetooth"; "Watch USB and external storage" = "Monitora USB e archiviazione esterna"; @@ -1234,13 +1206,8 @@ "Edit as normal rule…" = "Modifica come regola normale…"; /* UI/RulesView.swift */ -"Packs" = "Pacchetti"; -"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "Qui compaiono le decisioni delle regole e i blocchi riepilogati dei pacchetti. L’attività dei pacchetti viene raggruppata, così una pagina piena di pubblicità non diventa una pagina piena di avvisi."; "Managed packs" = "Pacchetti gestiti"; -"What they did" = "Cosa hanno fatto"; "Paused until %@" = "In pausa fino a %@"; -"%lld in force" = "%lld in vigore"; -"%1$lld of %2$lld in force" = "%1$lld di %2$lld in vigore"; "Templates" = "Modelli"; "Rules worth having, grouped by what they are for, with the hosts already written out" = "Regole utili, raggruppate per scopo, con gli host già scritti"; "Write a rule: an app, an agent, a destination or a URL, blocked or allowed" = "Scrivi una regola: un'app, un agente, una destinazione o un URL, bloccato o consentito"; @@ -1254,8 +1221,6 @@ "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld regole vengono osservate ma non applicate. Solo l'estensione di rete si trova sul percorso dei dati e può rifiutare una connessione; il campionatore nettop conta il traffico a posteriori. Seleziona l'estensione di rete in Origini › Collector per applicare queste regole."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "Una regola viene osservata ma non applicata: una regola che indica un percorso può essere confrontata solo dall'ispezione HTTPS, che è disattivata."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld regole vengono osservate ma non applicate: una regola che indica un percorso può essere confrontata solo dall'ispezione HTTPS, che è disattivata."; -"In force" = "In vigore"; -"Not in force" = "Non in vigore"; "Nothing is being refused until %@." = "Nulla viene rifiutato fino a %@."; "Resume now" = "Riprendi adesso"; "No rules yet" = "Ancora nessuna regola"; @@ -1265,7 +1230,6 @@ "Nothing yet" = "Ancora nulla"; "Today" = "Oggi"; "Yesterday" = "Ieri"; -"A rule since deleted" = "Una regola poi eliminata"; "Edit…" = "Modifica…"; "Reset Count" = "Azzera il conteggio"; "Delete" = "Elimina"; @@ -1887,3 +1851,192 @@ "Restart or reinstall the filter to finish the update" = "Riavvia o reinstalla il filtro per completare l'aggiornamento"; "Return to the previous history scope" = "Torna all'ambito precedente della cronologia"; "Selected" = "Selezionato"; + +/* 0.17.4 criteri unificati e origini */ +"Policies" = "Criteri"; +"Activity" = "Attività"; +"Choose the traffic source and verify that data is arriving." = "Scegli l'origine del traffico e verifica che i dati stiano arrivando."; +"Choose which local device channels Flowlight watches." = "Scegli quali canali dei dispositivi locali deve monitorare Flowlight."; +"Check what Flowlight can name, attribute, and inspect." = "Verifica che cosa Flowlight può nominare, attribuire e ispezionare."; +"Network rules" = "Regole di rete"; +"Agent allowlists" = "Elenchi consentiti degli agenti"; +"Agent guardrails" = "Protezioni degli agenti"; +"Request rewrites" = "Riscritture delle richieste"; +"Response transforms" = "Trasformazioni delle risposte"; +"In effect" = "Effettivo"; +"Reporting only" = "Solo segnalazione"; +"Waiting" = "In attesa"; +"Unavailable" = "Non disponibile"; +"Not applying" = "Non applicato"; +"HTTPS inspection is off." = "L'ispezione HTTPS è disattivata."; +"HTTPS inspection is not running." = "L'ispezione HTTPS non è in esecuzione."; +"This policy is switched off." = "Questo criterio è disattivato."; +"This rule is switched off." = "Questa regola è disattivata."; +"Network blocking is paused." = "Il blocco della rete è in pausa."; +"This one-off rule has already been used." = "Questa regola monouso è già stata utilizzata."; +"This rule is outside its scheduled time." = "Questa regola è fuori dall'orario programmato."; +"Every destination" = "Tutte le destinazioni"; +"No listed destinations" = "Nessuna destinazione elencata"; +"1 listed destination" = "1 destinazione elencata"; +"%lld listed destinations" = "%lld destinazioni elencate"; +"This allowlist is switched off." = "Questo elenco consentiti è disattivato."; +"Unlisted destinations raise alerts but are not blocked." = "Le destinazioni non elencate generano avvisi ma non vengono bloccate."; +"Blocking is unavailable; unlisted destinations still raise alerts." = "Il blocco non è disponibile; le destinazioni non elencate continuano a generare avvisi."; +"Review the changed pack before it can keep applying." = "Esamina il pacchetto modificato prima che possa continuare a essere applicato."; +"This pack is switched off." = "Questo pacchetto è disattivato."; +"The pack is matching destinations but cannot block them here." = "Il pacchetto trova corrispondenze tra le destinazioni ma qui non può bloccarle."; +"The pack is waiting for the active filter to apply it." = "Il pacchetto attende che il filtro attivo lo applichi."; +"The managed-pack engine is not applying this configuration." = "Il motore dei pacchetti gestiti non sta applicando questa configurazione."; +"No longer in the official catalog" = "Non è più nel catalogo ufficiale"; +"Blocking stopped automatically. Acknowledge this change after review." = "Il blocco è stato interrotto automaticamente. Dopo aver esaminato la modifica, confermala."; +"Every agent" = "Tutti gli agenti"; +"A policy since deleted" = "Un criterio poi eliminato"; +"%@ · %lld connections" = "%@ · %lld connessioni"; +"%lld policies · none currently effective" = "%lld criteri · nessuno attualmente applicato"; +"%1$lld policies · %2$lld currently effective" = "%1$lld criteri · %2$lld attualmente applicati"; +"Everything Flowlight can apply" = "Tutto ciò che Flowlight può applicare"; +"Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent." = "Qui trovi le decisioni di rete, i limiti degli agenti, gli elenchi gestiti di destinazioni e i criteri di ispezione per sviluppatori. Ogni criterio continua ad aprirsi nell'editor responsabile del relativo comportamento e consenso."; +"Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent." = "Qui trovi le decisioni di rete, i limiti degli agenti e gli elenchi gestiti di destinazioni. Ogni criterio continua ad aprirsi nell'editor responsabile del relativo comportamento e consenso."; +"%lld destinations · %@" = "%lld destinazioni · %@"; +"every app" = "ogni app"; +"Official · pack v%@ · %@" = "Ufficiale · pacchetto v%@ · %@"; +"Flowlight · signer %@" = "Flowlight · firmatario %@"; +"Catalog" = "Catalogo"; +"Sequence %1$lld · digest %2$@" = "Sequenza %1$lld · digest %2$@"; +"Last verified" = "Ultima verifica"; +"Next check" = "Prossimo controllo"; +"Official catalog details" = "Dettagli del catalogo ufficiale"; +"Not checked" = "Non controllato"; +"Verified" = "Verificato"; +"Stale" = "Obsoleto"; +"Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records." = "Blocchi, eccezioni, violazioni degli elenchi consentiti, protezioni, simulazioni e modifiche a richieste o risposte compaiono qui senza alterare i record originali."; +"Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records." = "Blocchi di rete, eccezioni, corrispondenze dei pacchetti gestiti e violazioni degli elenchi consentiti compaiono qui senza alterare i record originali."; +"Blocked connection or request" = "Connessione o richiesta bloccata"; +"Blocked by a managed pack" = "Bloccata da un pacchetto gestito"; +"Outside an agent allowlist" = "Fuori dall'elenco consentiti di un agente"; +"Capability removed by a guardrail" = "Funzionalità rimossa da una protezione"; +"Answered by a mock" = "Risposta fornita da una simulazione"; +"Request changed before sending" = "Richiesta modificata prima dell'invio"; +"Response changed before delivery" = "Risposta modificata prima della consegna"; + +/* 0.17.5 profili provider, inventario agenti e valutazione degli avvisi */ +"Couldn't update alerts: %@" = "Impossibile aggiornare gli avvisi: %@"; +"Needs attention" = "Richiede attenzione"; +"Ready" = "Pronto"; +"This provider profile is disabled." = "Questo profilo provider è disattivato."; +"Assign at least one inspection plugin." = "Assegna almeno un plugin di ispezione."; +"An assigned plugin is no longer installed." = "Un plugin assegnato non è più installato."; +"An assigned plugin uses a different provider." = "Un plugin assegnato usa un provider diverso."; +"Every assigned plugin is disabled." = "Tutti i plugin assegnati sono disattivati."; +"1 inspection plugin assigned." = "1 plugin di ispezione assegnato."; +"%lld inspection plugins assigned." = "%lld plugin di ispezione assegnati."; +"Enter a provider profile name between 1 and 80 characters." = "Inserisci un nome per il profilo provider compreso tra 1 e 80 caratteri."; +"Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed." = "Usa un endpoint HTTPS oppure HTTP solo per localhost. Le informazioni utente, i parametri di query e i frammenti non sono consentiti."; +"Use a Keychain reference such as keychain:service/account, not a credential value." = "Usa un riferimento al Portachiavi, ad esempio keychain:service/account, non il valore di una credenziale."; +"Provider options must be short, non-secret key/value metadata." = "Le opzioni del provider devono essere metadati chiave/valore brevi e non riservati."; +"An assigned plugin identifier is invalid." = "L'identificatore di un plugin assegnato non è valido."; +"Provider profiles could not be loaded. Create them again in Inspect." = "Impossibile caricare i profili provider. Creali di nuovo in Ispeziona."; +"Healthy" = "Operativo"; +"Needs setup" = "Da configurare"; +"Limited visibility" = "Visibilità limitata"; +"Attention" = "Attenzione"; +"Not observed" = "Non osservato"; +"Detected agent" = "Agente rilevato"; +"Traffic was observed in the selected window." = "È stato osservato traffico nell'intervallo selezionato."; +"Configuration was found, but no traffic was observed in the selected window." = "È stata trovata una configurazione, ma non è stato osservato traffico nell'intervallo selezionato."; +"Installed on this Mac, with no traffic in the selected window." = "Installato su questo Mac, senza traffico nell'intervallo selezionato."; +"No traffic was observed in the selected window." = "Non è stato osservato traffico nell'intervallo selezionato."; +"Always Monitor is configured, but no current route was observed." = "Always Monitor è configurato, ma non è stato osservato alcun percorso attuale."; +"Its route through Flowlight was observed." = "È stato osservato il suo percorso attraverso Flowlight."; +"The saved Flowlight route was not observed in this window." = "Il percorso Flowlight salvato non è stato osservato in questo intervallo."; +"Flowlight could not establish the requested route." = "Flowlight non ha potuto stabilire il percorso richiesto."; +"HTTPS inspection is unavailable in this build; only connection metadata is visible." = "L'ispezione HTTPS non è disponibile in questa build; sono visibili solo i metadati delle connessioni."; +"HTTPS inspection is off; request contents are not visible." = "L'ispezione HTTPS è disattivata; il contenuto delle richieste non è visibile."; +"The HTTPS inspection certificate is not trusted." = "Il certificato di ispezione HTTPS non è attendibile."; +"HTTPS inspection is enabled, but its proxy is not ready." = "L'ispezione HTTPS è attivata, ma il relativo proxy non è pronto."; +"No inspected requests were observed in this window." = "Non sono state osservate richieste ispezionate in questo intervallo."; +"HTTPS inspection observed this agent's traffic." = "L'ispezione HTTPS ha osservato il traffico di questo agente."; +"One observed destination is outside its policy." = "Una destinazione osservata non è inclusa nel relativo criterio."; +"%lld observed destinations are outside its policy." = "%lld destinazioni osservate non sono incluse nel relativo criterio."; +"Its enforcing destination policy covers the observed traffic." = "Il relativo criterio di applicazione per le destinazioni copre il traffico osservato."; +"Its reporting destination policy covers the observed traffic." = "Il relativo criterio di segnalazione per le destinazioni copre il traffico osservato."; +"No agent destination policy is enabled." = "Non è attivato alcun criterio per le destinazioni dell'agente."; +"One unresolved alert needs review." = "Un avviso non risolto deve essere esaminato."; +"%lld unresolved alerts need review." = "%lld avvisi non risolti devono essere esaminati."; +"Installed, configured, and observed AI agents, with explainable setup and visibility health." = "Agenti IA installati, configurati e osservati, con spiegazioni sulla configurazione e sullo stato di visibilità."; +"No AI agents found" = "Nessun agente IA trovato"; +"Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider." = "Flowlight cataloga le app supportate e gli agenti da riga di comando configurati e rileva i client non browser che contattano un provider IA noto."; +"Agents inventoried" = "Agenti catalogati"; +"Active in window" = "Attivi nell'intervallo"; +"Health" = "Stato"; +"Why" = "Motivo"; +"Policy" = "Criterio"; +"Not set" = "Non impostato"; +"Enforcing" = "Applicazione attiva"; +"Reporting" = "Solo segnalazione"; +"No traffic detail is available for this agent in the selected window." = "Nessun dettaglio sul traffico è disponibile per questo agente nell'intervallo selezionato."; +"No matching alerts" = "Nessun avviso corrispondente"; +"Nothing matches this rule" = "Nessun elemento corrisponde a questa regola"; +"State" = "Stato"; +"Disposition" = "Esito"; +"Acknowledge all new (%lld)" = "Conferma tutti i nuovi (%lld)"; +"Triage" = "Valutazione"; +"Update the selected alerts" = "Aggiorna gli avvisi selezionati"; +"Set State" = "Imposta stato"; +"Set Disposition" = "Imposta esito"; +"Edit Note…" = "Modifica nota…"; +"Triage Note" = "Nota di valutazione"; +"%lld open, no new" = "%lld aperti, nessuno nuovo"; +"%lld new of %lld open" = "%lld nuovi su %lld aperti"; +"%lld investigating" = "%lld in esame"; +"%lld resolved" = "%lld risolti"; +"%lld dismissed" = "%lld ignorati"; +"No open alerts match the current Focus scope." = "Nessun avviso aperto corrisponde all'ambito attuale di Focus."; +"No alerts are being investigated in the current Focus scope." = "Nessun avviso è in esame nell'ambito attuale di Focus."; +"No resolved alerts match the current Focus scope." = "Nessun avviso risolto corrisponde all'ambito attuale di Focus."; +"No dismissed alerts match the current Focus scope." = "Nessun avviso ignorato corrisponde all'ambito attuale di Focus."; +"New" = "Nuovo"; +"Acknowledged" = "Confermato"; +"Investigating" = "In esame"; +"Resolved" = "Risolto"; +"Dismissed" = "Ignorato"; +"Unset" = "Non impostato"; +"True positive" = "Vero positivo"; +"Expected / benign" = "Previsto / innocuo"; +"False positive" = "Falso positivo"; +"Open" = "Aperto"; +"Plugins & Providers" = "Plugin e provider"; +"Manage inspection plugins and local provider profiles" = "Gestisci i plugin di ispezione e i profili provider locali"; +"Live network traffic" = "Traffico di rete in tempo reale"; +"No time selected" = "Nessun momento selezionato"; +"Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second." = "I cerchi pieni indicano i download; i rombi tratteggiati indicano gli upload. Usa i tasti Freccia sinistra e Freccia destra per esaminare ogni secondo."; +"Download · solid" = "Download · pieno"; +"Upload · dashed" = "Upload · tratteggiato"; +"Inspection extensions, local routing metadata, and retained findings." = "Estensioni di ispezione, metadati di routing locale e risultati conservati."; +"No provider profiles" = "Nessun profilo provider"; +"Profiles group local provider settings with the inspection plugins that use them." = "I profili raggruppano le impostazioni locali dei provider con i plugin di ispezione che le usano."; +"Add Provider…" = "Aggiungi provider…"; +"Provider profiles" = "Profili provider"; +"Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile." = "I profili rimangono su questo Mac. Contengono opzioni non riservate e riferimenti facoltativi al Portachiavi; Flowlight non memorizza mai i valori delle credenziali in un profilo."; +"Saved provider profile %@." = "Profilo provider %@ salvato."; +"Remove provider profile?" = "Rimuovere il profilo provider?"; +"Removed provider profile %@." = "Profilo provider %@ rimosso."; +"Could not remove provider profile." = "Impossibile rimuovere il profilo provider."; +"Plugins and their retained findings stay installed." = "I plugin e i relativi risultati conservati rimangono installati."; +"Credential value kept in Keychain" = "Valore della credenziale conservato nel Portachiavi"; +"Add Provider Profile" = "Aggiungi profilo provider"; +"Edit Provider Profile" = "Modifica profilo provider"; +"Identity" = "Identità"; +"Enabled" = "Attivato"; +"Endpoint (optional)" = "Endpoint (facoltativo)"; +"Keychain reference (optional)" = "Riferimento al Portachiavi (facoltativo)"; +"Use keychain:service/account. The credential value is never stored in this profile." = "Usa keychain:service/account. Il valore della credenziale non viene mai memorizzato in questo profilo."; +"No installed plugins use this provider." = "Nessun plugin installato usa questo provider."; +"Plugin enabled" = "Plugin attivato"; +"Plugin disabled" = "Plugin disattivato"; +"Inspection plugins" = "Plugin di ispezione"; +"Option" = "Opzione"; +"Remove provider option" = "Rimuovi opzione del provider"; +"Add Option" = "Aggiungi opzione"; +"Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected." = "Le opzioni sono metadati locali non riservati. Chiavi API, token, password e altre credenziali non sono accettati."; +"Provider options" = "Opzioni del provider"; diff --git a/Flowlight/Localization/ja.lproj/Localizable.strings b/Flowlight/Localization/ja.lproj/Localizable.strings index 4f3e1460..094abddb 100644 --- a/Flowlight/Localization/ja.lproj/Localizable.strings +++ b/Flowlight/Localization/ja.lproj/Localizable.strings @@ -372,7 +372,6 @@ /* Inspection/InspectionController.swift */ "AI agents and their tools" = "AI エージェントとそのツール"; "Every app that uses the proxy" = "プロキシを使うすべてのアプリ"; -"Removed %@" = "%@ を削除しました"; "No network services to send through the proxy, so nothing would be inspected. Check System Settings › Network." = "プロキシ経由にできるネットワークサービスがないため、インスペクトするものがありません。システム設定 › ネットワーク を確認してください。"; "authorization failed" = "認証に失敗しました"; "Couldn't set Flowlight up: %@" = "Flowlight を設定できませんでした: %@"; @@ -423,24 +422,11 @@ "Last hour" = "過去 1 時間"; "24 hours" = "24 時間"; "7 days" = "7 日"; -"Observed AI agent activity, including destinations outside each agent's model provider." = "観測された AI エージェントの活動です。各エージェントのモデルプロバイダ以外の接続先も含みます。"; "Window" = "期間"; -"No AI agents seen" = "AI エージェントは検出されていません"; -"Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider." = "Flowlight は Claude Code、Codex、Cursor、Ollama などの対応エージェントを認識します。既知の LLM API プロバイダに接続するブラウザ以外のアプリも、エージェントとして分類します。"; -"Agents active" = "稼働中のエージェント"; -"AI API traffic" = "AI API のトラフィック"; -"Uploaded to other hosts" = "ほかのホストへの送信"; -"Using sensitive channels" = "重要なチャンネルの使用"; -"Agent alerts" = "エージェントのアラート"; "Agent" = "エージェント"; "Detected: calls an AI API" = "検出: AI API を呼び出しています"; "AI providers" = "AI プロバイダ"; -"AI traffic" = "AI トラフィック"; -"Other hosts" = "ほかのホスト"; -"Sent elsewhere" = "ほかへの送信"; -"%@ of this agent's uploads went to non-AI hosts" = "このエージェントの送信のうち %@ が AI 以外のホストへ送られました"; "Allowlist" = "許可リスト"; -"Risk" = "リスク"; "Show Traffic in History" = "トラフィックを履歴で表示"; "None seen" = "検出なし"; "Large upload" = "大量の送信"; @@ -558,12 +544,6 @@ "No %@ in this window. Flowlight is decrypting %@'s traffic; its model just hasn't asked for any." = "この期間に %@ はありません。Flowlight は %@ のトラフィックを復号していますが、モデルが要求しなかっただけです。"; /* UI/AlertsView.swift */ -"No alerts" = "アラートはありません"; -"Nothing to review" = "確認するものはありません"; -"Each app has a learning period (24 h by default) before first-contact and spike rules fire." = "アプリごとに学習期間(デフォルトは 24 時間)があり、その間は初回接続と急増のルールは発動しません。"; -"All matching alerts have been acknowledged." = "条件に合うアラートはすべて確認済みです。"; -"%lld alerts, all acknowledged" = "アラート %lld 件・すべて確認済み"; -"%lld to review of %lld" = "%2$lld 件中 %1$lld 件が未確認"; "Critical" = "重大"; "Warning" = "警告"; "Info" = "情報"; @@ -580,13 +560,7 @@ "Acknowledge" = "確認済みにする"; "Copy Detail" = "詳細をコピー"; "All rules" = "すべてのルール"; -"Show acknowledged" = "確認済みも表示"; "Filter which alerts are listed" = "表示するアラートを絞り込みます"; -"Selected (%lld)" = "選択した %lld 件"; -"All unacknowledged (%lld)" = "未確認のすべて(%lld)"; -"Mark alerts as seen" = "アラートを確認済みにします"; -"All rules, including seen" = "すべてのルール・確認済みも含む"; -"%@, including seen" = "%@・確認済みも含む"; /* UI/AskView.swift */ "%@ · leaves this Mac" = "%@ · この Mac の外に出ます"; @@ -752,8 +726,6 @@ "Seen as it happened, named, and readable." = "発生した時点で見えており、名前も付き、中身も読めています。"; /* UI/DevicesView.swift */ -"USB" = "USB"; -"Not watching" = "監視していません"; "Read the device list again now (⌘R)" = "デバイスのリストをいま読み直します(⌘R)"; "Watch Bluetooth" = "Bluetooth を監視"; "Watch USB and external storage" = "USB と外部ストレージを監視"; @@ -1234,13 +1206,8 @@ "Edit as normal rule…" = "通常のルールとして編集…"; /* UI/RulesView.swift */ -"Packs" = "パック"; -"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "ルールの判断とパックによるブロックの要約がここに表示されます。広告だらけのページがアラートだらけにならないよう、パックのアクティビティはまとめられます。"; "Managed packs" = "管理対象パック"; -"What they did" = "判断の記録"; "Paused until %@" = "%@ まで一時停止中"; -"%lld in force" = "%lld 件が有効"; -"%1$lld of %2$lld in force" = "%2$lld 件中 %1$lld 件が有効"; "Templates" = "テンプレート"; "Rules worth having, grouped by what they are for, with the hosts already written out" = "用意しておく価値のあるルールを用途別にまとめました。ホストはすでに書き込まれています"; "Write a rule: an app, an agent, a destination or a URL, blocked or allowed" = "ルールを作成します。アプリ、エージェント、接続先、URL のいずれかを、ブロックまたは許可します"; @@ -1254,8 +1221,6 @@ "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld 件のルールは監視されているだけで、実行されていません。データ経路に入って接続を拒否できるのはネットワーク機能拡張だけで、nettop サンプラーは事後にトラフィックを数えます。これらのルールを実行するには、ソース › コレクターでネットワーク機能拡張を選んでください。"; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "1 件のルールは監視されているだけで、実行されていません。パスを指定したルールを照合できるのは HTTPS インスペクションだけで、それがオフになっています。"; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld 件のルールは監視されているだけで、実行されていません。パスを指定したルールを照合できるのは HTTPS インスペクションだけで、それがオフになっています。"; -"In force" = "有効"; -"Not in force" = "無効"; "Nothing is being refused until %@." = "%@ まで、何も拒否しません。"; "Resume now" = "今すぐ再開"; "No rules yet" = "ルールがありません"; @@ -1265,7 +1230,6 @@ "Nothing yet" = "まだ何もありません"; "Today" = "今日"; "Yesterday" = "昨日"; -"A rule since deleted" = "すでに削除されたルール"; "Edit…" = "編集…"; "Reset Count" = "カウントをリセット"; "Delete" = "削除"; @@ -1887,3 +1851,192 @@ "Restart or reinstall the filter to finish the update" = "フィルタのアップデートを完了するには再起動または再インストールしてください"; "Return to the previous history scope" = "前の履歴範囲に戻る"; "Selected" = "選択中"; + +/* 0.17.4 unified policies and sources */ +"Policies" = "ポリシー"; +"Activity" = "アクティビティ"; +"Choose the traffic source and verify that data is arriving." = "トラフィックソースを選択し、データが届いていることを確認します。"; +"Choose which local device channels Flowlight watches." = "Flowlight が監視するローカルデバイスのチャンネルを選択します。"; +"Check what Flowlight can name, attribute, and inspect." = "Flowlight が名前を付け、関連付け、インスペクトできる範囲を確認します。"; +"Network rules" = "ネットワークルール"; +"Agent allowlists" = "エージェントの許可リスト"; +"Agent guardrails" = "エージェントのガードレール"; +"Request rewrites" = "リクエストの書き換え"; +"Response transforms" = "レスポンスの変換"; +"In effect" = "適用中"; +"Reporting only" = "報告のみ"; +"Waiting" = "待機中"; +"Unavailable" = "利用不可"; +"Not applying" = "未適用"; +"HTTPS inspection is off." = "HTTPS インスペクションはオフです。"; +"HTTPS inspection is not running." = "HTTPS インスペクションは稼働していません。"; +"This policy is switched off." = "このポリシーはオフです。"; +"This rule is switched off." = "このルールはオフです。"; +"Network blocking is paused." = "ネットワークのブロックは一時停止中です。"; +"This one-off rule has already been used." = "この一度きりのルールはすでに使用済みです。"; +"This rule is outside its scheduled time." = "このルールは現在、設定された時間帯の外です。"; +"Every destination" = "すべての接続先"; +"No listed destinations" = "登録された接続先なし"; +"1 listed destination" = "登録された接続先 1 件"; +"%lld listed destinations" = "登録された接続先 %lld 件"; +"This allowlist is switched off." = "この許可リストはオフです。"; +"Unlisted destinations raise alerts but are not blocked." = "リストにない接続先はアラートを出しますが、ブロックされません。"; +"Blocking is unavailable; unlisted destinations still raise alerts." = "ブロックは利用できませんが、リストにない接続先は引き続きアラートを出します。"; +"Review the changed pack before it can keep applying." = "変更されたパックを確認すると、再び適用できるようになります。"; +"This pack is switched off." = "このパックはオフです。"; +"The pack is matching destinations but cannot block them here." = "このパックは接続先に一致していますが、ここではブロックできません。"; +"The pack is waiting for the active filter to apply it." = "このパックは、有効なフィルタによる適用を待っています。"; +"The managed-pack engine is not applying this configuration." = "管理対象パックのエンジンは、この設定を適用していません。"; +"No longer in the official catalog" = "公式カタログから削除済み"; +"Blocking stopped automatically. Acknowledge this change after review." = "ブロックは自動的に停止しました。確認後、この変更を承認してください。"; +"Every agent" = "すべてのエージェント"; +"A policy since deleted" = "すでに削除されたポリシー"; +"%@ · %lld connections" = "%@ · 接続 %lld 件"; +"%lld policies · none currently effective" = "ポリシー %lld 件 · 現在適用中のものはありません"; +"%1$lld policies · %2$lld currently effective" = "ポリシー %1$lld 件 · 現在 %2$lld 件が適用中"; +"Everything Flowlight can apply" = "Flowlight が適用できるすべての制御"; +"Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent." = "ネットワークの判断、エージェントの制限、管理対象の接続先リスト、開発者向けインスペクションポリシーをここにまとめます。各ポリシーを選ぶと、その動作と同意を管理する本来のエディタが開きます。"; +"Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent." = "ネットワークの判断、エージェントの制限、管理対象の接続先リストをここにまとめます。各ポリシーを選ぶと、その動作と同意を管理する本来のエディタが開きます。"; +"%lld destinations · %@" = "接続先 %lld 件 · %@"; +"every app" = "すべてのアプリ"; +"Official · pack v%@ · %@" = "公式 · パック v%@ · %@"; +"Flowlight · signer %@" = "Flowlight · 署名者 %@"; +"Catalog" = "カタログ"; +"Sequence %1$lld · digest %2$@" = "シーケンス %1$lld · ダイジェスト %2$@"; +"Last verified" = "最終検証"; +"Next check" = "次回確認"; +"Official catalog details" = "公式カタログの詳細"; +"Not checked" = "未確認"; +"Verified" = "検証済み"; +"Stale" = "古い"; +"Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records." = "ブロック、例外、許可リスト違反、ガードレール、モック、リクエストやレスポンスの変更を、元の記録を変えずにここへ表示します。"; +"Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records." = "ネットワークのブロック、例外、管理対象パックへの一致、許可リスト違反を、元の記録を変えずにここへ表示します。"; +"Blocked connection or request" = "ブロックされた接続またはリクエスト"; +"Blocked by a managed pack" = "管理対象パックによるブロック"; +"Outside an agent allowlist" = "エージェントの許可リスト外"; +"Capability removed by a guardrail" = "ガードレールによって取り除かれた機能"; +"Answered by a mock" = "モックによる応答"; +"Request changed before sending" = "送信前に変更されたリクエスト"; +"Response changed before delivery" = "受け渡し前に変更されたレスポンス"; + +/* 0.17.5 プロバイダプロファイル、エージェント一覧、アラートのトリアージ */ +"Couldn't update alerts: %@" = "アラートを更新できませんでした:%@"; +"Needs attention" = "要確認"; +"Ready" = "準備完了"; +"This provider profile is disabled." = "このプロバイダプロファイルは無効です。"; +"Assign at least one inspection plugin." = "インスペクションプラグインを 1 つ以上割り当ててください。"; +"An assigned plugin is no longer installed." = "割り当てたプラグインがインストールされていません。"; +"An assigned plugin uses a different provider." = "割り当てたプラグインは別のプロバイダを使用しています。"; +"Every assigned plugin is disabled." = "割り当てたプラグインはすべて無効です。"; +"1 inspection plugin assigned." = "インスペクションプラグインが 1 つ割り当てられています。"; +"%lld inspection plugins assigned." = "インスペクションプラグインが %lld 個割り当てられています。"; +"Enter a provider profile name between 1 and 80 characters." = "プロバイダプロファイル名を 1〜80 文字で入力してください。"; +"Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed." = "HTTPS エンドポイントを使用してください。HTTP は localhost でのみ使用できます。ユーザ情報、クエリパラメータ、フラグメントは使用できません。"; +"Use a Keychain reference such as keychain:service/account, not a credential value." = "認証情報の値ではなく、keychain:service/account のようなキーチェーン参照を使用してください。"; +"Provider options must be short, non-secret key/value metadata." = "プロバイダオプションには、短い非機密のキーと値のメタデータを指定してください。"; +"An assigned plugin identifier is invalid." = "割り当てたプラグインの識別子が無効です。"; +"Provider profiles could not be loaded. Create them again in Inspect." = "プロバイダプロファイルを読み込めませんでした。インスペクトで作り直してください。"; +"Healthy" = "正常"; +"Needs setup" = "設定が必要"; +"Limited visibility" = "可視性が限定的"; +"Attention" = "注意"; +"Not observed" = "未観測"; +"Detected agent" = "検出したエージェント"; +"Traffic was observed in the selected window." = "選択した期間にトラフィックが観測されました。"; +"Configuration was found, but no traffic was observed in the selected window." = "設定は見つかりましたが、選択した期間にトラフィックは観測されませんでした。"; +"Installed on this Mac, with no traffic in the selected window." = "この Mac にインストールされていますが、選択した期間にトラフィックはありません。"; +"No traffic was observed in the selected window." = "選択した期間にトラフィックは観測されませんでした。"; +"Always Monitor is configured, but no current route was observed." = "Always Monitor は設定されていますが、現在の経路は観測されませんでした。"; +"Its route through Flowlight was observed." = "Flowlight を経由する経路が観測されました。"; +"The saved Flowlight route was not observed in this window." = "保存された Flowlight の経路は、この期間に観測されませんでした。"; +"Flowlight could not establish the requested route." = "Flowlight は要求された経路を確立できませんでした。"; +"HTTPS inspection is unavailable in this build; only connection metadata is visible." = "このビルドでは HTTPS インスペクションを利用できません。接続のメタデータのみ表示できます。"; +"HTTPS inspection is off; request contents are not visible." = "HTTPS インスペクションがオフのため、リクエストの内容は表示できません。"; +"The HTTPS inspection certificate is not trusted." = "HTTPS インスペクション証明書が信頼されていません。"; +"HTTPS inspection is enabled, but its proxy is not ready." = "HTTPS インスペクションは有効ですが、プロキシの準備ができていません。"; +"No inspected requests were observed in this window." = "この期間にインスペクト済みのリクエストは観測されませんでした。"; +"HTTPS inspection observed this agent's traffic." = "HTTPS インスペクションでこのエージェントのトラフィックが観測されました。"; +"One observed destination is outside its policy." = "観測された接続先のうち 1 件がポリシーの対象外です。"; +"%lld observed destinations are outside its policy." = "観測された接続先のうち %lld 件がポリシーの対象外です。"; +"Its enforcing destination policy covers the observed traffic." = "適用中の接続先ポリシーが観測されたトラフィックをカバーしています。"; +"Its reporting destination policy covers the observed traffic." = "報告用の接続先ポリシーが観測されたトラフィックをカバーしています。"; +"No agent destination policy is enabled." = "エージェントの接続先ポリシーは有効になっていません。"; +"One unresolved alert needs review." = "未解決のアラート 1 件を確認する必要があります。"; +"%lld unresolved alerts need review." = "未解決のアラート %lld 件を確認する必要があります。"; +"Installed, configured, and observed AI agents, with explainable setup and visibility health." = "インストール済み、設定済み、観測済みの AI エージェントと、設定および可視性の状態を根拠付きで表示します。"; +"No AI agents found" = "AI エージェントが見つかりません"; +"Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider." = "Flowlight は対応アプリと設定済みのコマンドラインエージェントを一覧化し、既知の AI プロバイダに接続するブラウザ以外のクライアントを検出します。"; +"Agents inventoried" = "一覧化したエージェント"; +"Active in window" = "期間内に稼働"; +"Health" = "状態"; +"Why" = "理由"; +"Policy" = "ポリシー"; +"Not set" = "未設定"; +"Enforcing" = "適用中"; +"Reporting" = "報告のみ"; +"No traffic detail is available for this agent in the selected window." = "選択した期間について、このエージェントのトラフィック詳細はありません。"; +"No matching alerts" = "該当するアラートはありません"; +"Nothing matches this rule" = "このルールに一致するものはありません"; +"State" = "状態"; +"Disposition" = "判定"; +"Acknowledge all new (%lld)" = "新規をすべて確認済みにする(%lld)"; +"Triage" = "トリアージ"; +"Update the selected alerts" = "選択したアラートを更新"; +"Set State" = "状態を設定"; +"Set Disposition" = "判定を設定"; +"Edit Note…" = "メモを編集…"; +"Triage Note" = "トリアージメモ"; +"%lld open, no new" = "未対応 %lld 件、新規なし"; +"%lld new of %lld open" = "新規 %lld 件 / 未対応 %lld 件"; +"%lld investigating" = "調査中 %lld 件"; +"%lld resolved" = "解決済み %lld 件"; +"%lld dismissed" = "対象外 %lld 件"; +"No open alerts match the current Focus scope." = "現在の Focus の範囲に一致する未対応のアラートはありません。"; +"No alerts are being investigated in the current Focus scope." = "現在の Focus の範囲で調査中のアラートはありません。"; +"No resolved alerts match the current Focus scope." = "現在の Focus の範囲に一致する解決済みのアラートはありません。"; +"No dismissed alerts match the current Focus scope." = "現在の Focus の範囲に一致する対象外のアラートはありません。"; +"New" = "新規"; +"Acknowledged" = "確認済み"; +"Investigating" = "調査中"; +"Resolved" = "解決済み"; +"Dismissed" = "対象外"; +"Unset" = "未設定"; +"True positive" = "真陽性"; +"Expected / benign" = "想定内 / 無害"; +"False positive" = "誤検知"; +"Open" = "未対応"; +"Plugins & Providers" = "プラグインとプロバイダ"; +"Manage inspection plugins and local provider profiles" = "インスペクションプラグインとローカルのプロバイダプロファイルを管理"; +"Live network traffic" = "リアルタイムのネットワークトラフィック"; +"No time selected" = "時刻が選択されていません"; +"Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second." = "塗りつぶした円はダウンロード、破線のひし形はアップロードです。左右の矢印キーを使って 1 秒ずつ確認できます。"; +"Download · solid" = "ダウンロード · 塗りつぶし"; +"Upload · dashed" = "アップロード · 破線"; +"Inspection extensions, local routing metadata, and retained findings." = "インスペクション拡張、ローカル経路のメタデータ、保持されている検出結果です。"; +"No provider profiles" = "プロバイダプロファイルはありません"; +"Profiles group local provider settings with the inspection plugins that use them." = "プロファイルは、ローカルのプロバイダ設定と、それを使用するインスペクションプラグインをまとめます。"; +"Add Provider…" = "プロバイダを追加…"; +"Provider profiles" = "プロバイダプロファイル"; +"Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile." = "プロファイルはこの Mac に保存されます。非機密のオプションと任意のキーチェーン参照が含まれますが、Flowlight が認証情報の値をプロファイルに保存することはありません。"; +"Saved provider profile %@." = "プロバイダプロファイル %@ を保存しました。"; +"Remove provider profile?" = "プロバイダプロファイルを削除しますか?"; +"Removed provider profile %@." = "プロバイダプロファイル %@ を削除しました。"; +"Could not remove provider profile." = "プロバイダプロファイルを削除できませんでした。"; +"Plugins and their retained findings stay installed." = "プラグインと保持されている検出結果はインストールされたままです。"; +"Credential value kept in Keychain" = "認証情報の値はキーチェーンに保存されます"; +"Add Provider Profile" = "プロバイダプロファイルを追加"; +"Edit Provider Profile" = "プロバイダプロファイルを編集"; +"Identity" = "識別情報"; +"Enabled" = "有効"; +"Endpoint (optional)" = "エンドポイント(任意)"; +"Keychain reference (optional)" = "キーチェーン参照(任意)"; +"Use keychain:service/account. The credential value is never stored in this profile." = "keychain:service/account を使用してください。認証情報の値がこのプロファイルに保存されることはありません。"; +"No installed plugins use this provider." = "インストール済みのプラグインはこのプロバイダを使用していません。"; +"Plugin enabled" = "プラグインは有効です"; +"Plugin disabled" = "プラグインは無効です"; +"Inspection plugins" = "インスペクションプラグイン"; +"Option" = "オプション"; +"Remove provider option" = "プロバイダオプションを削除"; +"Add Option" = "オプションを追加"; +"Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected." = "オプションはローカルの非機密メタデータです。API キー、トークン、パスワードなどの認証情報は使用できません。"; +"Provider options" = "プロバイダオプション"; diff --git a/Flowlight/Localization/ko.lproj/Localizable.strings b/Flowlight/Localization/ko.lproj/Localizable.strings index 6ad2abc5..ef11caf7 100644 --- a/Flowlight/Localization/ko.lproj/Localizable.strings +++ b/Flowlight/Localization/ko.lproj/Localizable.strings @@ -395,7 +395,6 @@ /* Inspection/InspectionController.swift */ "AI agents and their tools" = "AI 에이전트와 그 도구"; "Every app that uses the proxy" = "프록시를 사용하는 모든 앱"; -"Removed %@" = "%@ 제거됨"; "No network services to send through the proxy, so nothing would be inspected. Check System Settings › Network." = "프록시로 보낼 네트워크 서비스가 없어 검사할 대상이 없습니다. 시스템 설정 › 네트워크를 확인하세요."; "authorization failed" = "인증 실패"; "Couldn't set Flowlight up: %@" = "Flowlight를 설정할 수 없습니다: %@"; @@ -446,24 +445,11 @@ "Last hour" = "최근 한 시간"; "24 hours" = "24시간"; "7 days" = "7일"; -"Observed AI agent activity, including destinations outside each agent's model provider." = "관찰된 AI 에이전트 활동입니다. 각 에이전트의 모델 제공업체 외부 목적지도 포함합니다."; "Window" = "기간"; -"No AI agents seen" = "확인된 AI 에이전트 없음"; -"Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider." = "Flowlight는 Claude Code, Codex, Cursor, Ollama 등 지원되는 에이전트를 인식합니다. 알려진 LLM API 제공업체에 연결하는 비브라우저 앱도 에이전트로 분류합니다."; -"Agents active" = "활동 중인 에이전트"; -"AI API traffic" = "AI API 트래픽"; -"Uploaded to other hosts" = "다른 호스트로 업로드"; -"Using sensitive channels" = "민감한 채널 사용"; -"Agent alerts" = "에이전트 알림"; "Agent" = "에이전트"; "Detected: calls an AI API" = "감지됨: AI API 호출"; "AI providers" = "AI 제공업체"; -"AI traffic" = "AI 트래픽"; -"Other hosts" = "다른 호스트"; -"Sent elsewhere" = "외부 전송"; -"%@ of this agent's uploads went to non-AI hosts" = "이 에이전트의 업로드 중 AI 외 호스트로 간 양: %@"; "Allowlist" = "허용 목록"; -"Risk" = "위험도"; "Show Traffic in History" = "기록에서 트래픽 보기"; "None seen" = "확인된 항목 없음"; "Large upload" = "대용량 업로드"; @@ -581,12 +567,6 @@ "No %@ in this window. Flowlight is decrypting %@'s traffic; its model just hasn't asked for any." = "이 기간에는 %@ 기록이 없습니다. Flowlight가 %@의 트래픽을 복호화하고 있지만, 모델이 아무것도 요청하지 않았습니다."; /* UI/AlertsView.swift */ -"No alerts" = "알림 없음"; -"Nothing to review" = "검토할 항목 없음"; -"Each app has a learning period (24 h by default) before first-contact and spike rules fire." = "각 앱에는 학습 기간(기본 24시간)이 있으며, 그 기간이 지나야 첫 연결과 급증 규칙이 작동합니다."; -"All matching alerts have been acknowledged." = "조건에 맞는 알림을 모두 확인 처리했습니다."; -"%lld alerts, all acknowledged" = "알림 %lld건, 모두 확인 처리됨"; -"%lld to review of %lld" = "전체 %2$lld건 중 %1$lld건 검토 필요"; "Critical" = "심각"; "Warning" = "경고"; "Info" = "정보"; @@ -603,13 +583,7 @@ "Acknowledge" = "확인 처리"; "Copy Detail" = "세부 정보 복사"; "All rules" = "모든 규칙"; -"Show acknowledged" = "확인한 알림 표시"; "Filter which alerts are listed" = "표시할 알림 필터링"; -"Selected (%lld)" = "선택한 항목 (%lld)"; -"All unacknowledged (%lld)" = "확인하지 않은 항목 전체 (%lld)"; -"Mark alerts as seen" = "알림을 확인함으로 표시"; -"All rules, including seen" = "모든 규칙, 확인한 알림 포함"; -"%@, including seen" = "%@, 확인한 알림 포함"; /* UI/AskView.swift */ "%@ · leaves this Mac" = "%@ · 이 Mac을 떠납니다"; @@ -775,8 +749,6 @@ "Seen as it happened, named, and readable." = "일어난 순간에 포착되고, 이름이 붙고, 내용도 읽혔습니다."; /* UI/DevicesView.swift */ -"USB" = "USB"; -"Not watching" = "감시하지 않음"; "Read the device list again now (⌘R)" = "지금 기기 목록 다시 읽기 (⌘R)"; "Watch Bluetooth" = "Bluetooth 감시"; "Watch USB and external storage" = "USB 및 외장 저장 장치 감시"; @@ -1257,13 +1229,8 @@ "Edit as normal rule…" = "일반 규칙으로 편집…"; /* UI/RulesView.swift */ -"Packs" = "팩"; -"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "규칙 판단과 팩 차단 요약이 여기에 표시됩니다. 광고가 많은 페이지 때문에 알림이 가득 차지 않도록 팩 활동은 묶어서 표시됩니다."; "Managed packs" = "관리형 팩"; -"What they did" = "동작 내역"; "Paused until %@" = "%@까지 일시 중지됨"; -"%lld in force" = "%lld개 적용 중"; -"%1$lld of %2$lld in force" = "%2$lld개 중 %1$lld개 적용 중"; "Templates" = "템플릿"; "Rules worth having, grouped by what they are for, with the hosts already written out" = "쓸 만한 규칙을 용도별로 모아 두었고, 호스트도 이미 채워져 있습니다"; "Write a rule: an app, an agent, a destination or a URL, blocked or allowed" = "규칙 작성: 앱, 에이전트, 목적지 또는 URL을 차단하거나 허용합니다"; @@ -1277,8 +1244,6 @@ "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "규칙 %lld개가 관찰만 되고 실행되지는 않습니다. 데이터 경로에 자리해 연결을 거부할 수 있는 것은 네트워크 확장 프로그램뿐이며, nettop 샘플러는 트래픽을 사후에 집계합니다. 이 규칙을 적용하려면 소스 › 컬렉터에서 네트워크 확장 프로그램을 선택하세요."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "규칙 1개가 관찰만 되고 실행되지는 않습니다. 경로를 지정한 규칙은 HTTPS 검사로만 일치시킬 수 있는데, 검사가 꺼져 있습니다."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "규칙 %lld개가 관찰만 되고 실행되지는 않습니다. 경로를 지정한 규칙은 HTTPS 검사로만 일치시킬 수 있는데, 검사가 꺼져 있습니다."; -"In force" = "적용 중"; -"Not in force" = "적용되지 않음"; "Nothing is being refused until %@." = "%@까지는 아무것도 거부하지 않습니다."; "Resume now" = "지금 다시 시작"; "No rules yet" = "아직 규칙이 없습니다"; @@ -1288,7 +1253,6 @@ "Nothing yet" = "아직 없음"; "Today" = "오늘"; "Yesterday" = "어제"; -"A rule since deleted" = "이후 삭제된 규칙"; "Edit…" = "편집…"; "Reset Count" = "횟수 재설정"; "Delete" = "삭제"; @@ -1886,3 +1850,192 @@ "Restart or reinstall the filter to finish the update" = "업데이트를 완료하려면 필터를 재시작하거나 다시 설치하세요"; "Return to the previous history scope" = "이전 기록 범위로 돌아가기"; "Selected" = "선택됨"; + +/* 0.17.4 unified policies and sources */ +"Policies" = "정책"; +"Activity" = "활동"; +"Choose the traffic source and verify that data is arriving." = "트래픽 소스를 선택하고 데이터가 들어오는지 확인합니다."; +"Choose which local device channels Flowlight watches." = "Flowlight가 감시할 로컬 기기 채널을 선택합니다."; +"Check what Flowlight can name, attribute, and inspect." = "Flowlight가 이름을 붙이고, 출처를 연결하고, 검사할 수 있는 범위를 확인합니다."; +"Network rules" = "네트워크 규칙"; +"Agent allowlists" = "에이전트 허용 목록"; +"Agent guardrails" = "에이전트 가드레일"; +"Request rewrites" = "요청 다시 쓰기"; +"Response transforms" = "응답 변환"; +"In effect" = "적용 중"; +"Reporting only" = "보고만 함"; +"Waiting" = "대기 중"; +"Unavailable" = "사용할 수 없음"; +"Not applying" = "적용되지 않음"; +"HTTPS inspection is off." = "HTTPS 검사가 꺼져 있습니다."; +"HTTPS inspection is not running." = "HTTPS 검사가 실행 중이 아닙니다."; +"This policy is switched off." = "이 정책은 꺼져 있습니다."; +"This rule is switched off." = "이 규칙은 꺼져 있습니다."; +"Network blocking is paused." = "네트워크 차단이 일시 중지되었습니다."; +"This one-off rule has already been used." = "이 일회성 규칙은 이미 사용되었습니다."; +"This rule is outside its scheduled time." = "이 규칙은 현재 지정된 시간대 밖에 있습니다."; +"Every destination" = "모든 목적지"; +"No listed destinations" = "등록된 목적지 없음"; +"1 listed destination" = "등록된 목적지 1개"; +"%lld listed destinations" = "등록된 목적지 %lld개"; +"This allowlist is switched off." = "이 허용 목록은 꺼져 있습니다."; +"Unlisted destinations raise alerts but are not blocked." = "목록에 없는 목적지는 알림을 띄우지만 차단하지 않습니다."; +"Blocking is unavailable; unlisted destinations still raise alerts." = "차단을 사용할 수 없지만, 목록에 없는 목적지는 계속 알림을 띄웁니다."; +"Review the changed pack before it can keep applying." = "변경된 팩을 검토해야 다시 적용할 수 있습니다."; +"This pack is switched off." = "이 팩은 꺼져 있습니다."; +"The pack is matching destinations but cannot block them here." = "이 팩은 목적지를 찾아내고 있지만 여기서는 차단할 수 없습니다."; +"The pack is waiting for the active filter to apply it." = "이 팩은 활성 필터가 적용하기를 기다리고 있습니다."; +"The managed-pack engine is not applying this configuration." = "관리형 팩 엔진이 이 구성을 적용하지 않고 있습니다."; +"No longer in the official catalog" = "공식 카탈로그에서 삭제됨"; +"Blocking stopped automatically. Acknowledge this change after review." = "차단이 자동으로 중지되었습니다. 검토한 뒤 이 변경을 확인하세요."; +"Every agent" = "모든 에이전트"; +"A policy since deleted" = "이후 삭제된 정책"; +"%@ · %lld connections" = "%@ · 연결 %lld개"; +"%lld policies · none currently effective" = "정책 %lld개 · 현재 적용 중인 정책 없음"; +"%1$lld policies · %2$lld currently effective" = "정책 %1$lld개 · 현재 %2$lld개 적용 중"; +"Everything Flowlight can apply" = "Flowlight가 적용할 수 있는 모든 제어"; +"Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent." = "네트워크 판정, 에이전트 제한, 관리형 목적지 목록, 개발자용 검사 정책을 한곳에서 봅니다. 각 정책을 선택하면 그 동작과 동의를 관리하는 원래 편집기가 열립니다."; +"Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent." = "네트워크 판정, 에이전트 제한, 관리형 목적지 목록을 한곳에서 봅니다. 각 정책을 선택하면 그 동작과 동의를 관리하는 원래 편집기가 열립니다."; +"%lld destinations · %@" = "목적지 %lld개 · %@"; +"every app" = "모든 앱"; +"Official · pack v%@ · %@" = "공식 · 팩 v%@ · %@"; +"Flowlight · signer %@" = "Flowlight · 서명자 %@"; +"Catalog" = "카탈로그"; +"Sequence %1$lld · digest %2$@" = "순번 %1$lld · 다이제스트 %2$@"; +"Last verified" = "마지막 검증"; +"Next check" = "다음 확인"; +"Official catalog details" = "공식 카탈로그 세부 정보"; +"Not checked" = "확인하지 않음"; +"Verified" = "검증됨"; +"Stale" = "오래됨"; +"Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records." = "차단, 예외, 허용 목록 위반, 가드레일, 모의 응답, 요청·응답 변경을 원래 기록은 그대로 둔 채 여기에 표시합니다."; +"Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records." = "네트워크 차단, 예외, 관리형 팩 일치, 허용 목록 위반을 원래 기록은 그대로 둔 채 여기에 표시합니다."; +"Blocked connection or request" = "차단된 연결 또는 요청"; +"Blocked by a managed pack" = "관리형 팩으로 차단됨"; +"Outside an agent allowlist" = "에이전트 허용 목록 밖"; +"Capability removed by a guardrail" = "가드레일이 제거한 기능"; +"Answered by a mock" = "모의 응답으로 처리됨"; +"Request changed before sending" = "전송 전에 변경된 요청"; +"Response changed before delivery" = "전달 전에 변경된 응답"; + +/* 0.17.5 제공업체 프로필, 에이전트 목록 및 알림 분류 */ +"Couldn't update alerts: %@" = "알림을 업데이트할 수 없습니다: %@"; +"Needs attention" = "확인 필요"; +"Ready" = "준비됨"; +"This provider profile is disabled." = "이 제공업체 프로필은 비활성화되어 있습니다."; +"Assign at least one inspection plugin." = "검사 플러그인을 하나 이상 지정하세요."; +"An assigned plugin is no longer installed." = "지정된 플러그인이 더 이상 설치되어 있지 않습니다."; +"An assigned plugin uses a different provider." = "지정된 플러그인이 다른 제공업체를 사용합니다."; +"Every assigned plugin is disabled." = "지정된 플러그인이 모두 비활성화되어 있습니다."; +"1 inspection plugin assigned." = "검사 플러그인 1개가 지정되었습니다."; +"%lld inspection plugins assigned." = "검사 플러그인 %lld개가 지정되었습니다."; +"Enter a provider profile name between 1 and 80 characters." = "제공업체 프로필 이름을 1~80자로 입력하세요."; +"Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed." = "HTTPS 엔드포인트를 사용하세요. HTTP는 localhost에만 사용할 수 있습니다. 사용자 정보, 쿼리 매개변수와 프래그먼트는 허용되지 않습니다."; +"Use a Keychain reference such as keychain:service/account, not a credential value." = "자격 증명 값 대신 keychain:service/account와 같은 키체인 참조를 사용하세요."; +"Provider options must be short, non-secret key/value metadata." = "제공업체 옵션은 짧고 기밀이 아닌 키/값 메타데이터여야 합니다."; +"An assigned plugin identifier is invalid." = "지정된 플러그인 식별자가 올바르지 않습니다."; +"Provider profiles could not be loaded. Create them again in Inspect." = "제공업체 프로필을 불러올 수 없습니다. 검사에서 다시 생성하세요."; +"Healthy" = "정상"; +"Needs setup" = "설정 필요"; +"Limited visibility" = "제한된 가시성"; +"Attention" = "주의"; +"Not observed" = "관찰되지 않음"; +"Detected agent" = "감지된 에이전트"; +"Traffic was observed in the selected window." = "선택한 기간에 트래픽이 관찰되었습니다."; +"Configuration was found, but no traffic was observed in the selected window." = "구성을 찾았지만 선택한 기간에 관찰된 트래픽은 없습니다."; +"Installed on this Mac, with no traffic in the selected window." = "이 Mac에 설치되어 있지만 선택한 기간에 트래픽은 없습니다."; +"No traffic was observed in the selected window." = "선택한 기간에 관찰된 트래픽이 없습니다."; +"Always Monitor is configured, but no current route was observed." = "Always Monitor가 구성되어 있지만 현재 경로는 관찰되지 않았습니다."; +"Its route through Flowlight was observed." = "Flowlight를 통과하는 경로가 관찰되었습니다."; +"The saved Flowlight route was not observed in this window." = "저장된 Flowlight 경로가 이 기간에는 관찰되지 않았습니다."; +"Flowlight could not establish the requested route." = "Flowlight가 요청한 경로를 설정하지 못했습니다."; +"HTTPS inspection is unavailable in this build; only connection metadata is visible." = "이 빌드에서는 HTTPS 검사를 사용할 수 없어 연결 메타데이터만 볼 수 있습니다."; +"HTTPS inspection is off; request contents are not visible." = "HTTPS 검사가 꺼져 있어 요청 내용을 볼 수 없습니다."; +"The HTTPS inspection certificate is not trusted." = "HTTPS 검사 인증서를 신뢰하지 않습니다."; +"HTTPS inspection is enabled, but its proxy is not ready." = "HTTPS 검사가 활성화되어 있지만 프록시가 준비되지 않았습니다."; +"No inspected requests were observed in this window." = "이 기간에는 검사된 요청이 관찰되지 않았습니다."; +"HTTPS inspection observed this agent's traffic." = "HTTPS 검사에서 이 에이전트의 트래픽을 관찰했습니다."; +"One observed destination is outside its policy." = "관찰된 목적지 1개가 정책 범위 밖에 있습니다."; +"%lld observed destinations are outside its policy." = "관찰된 목적지 %lld개가 정책 범위 밖에 있습니다."; +"Its enforcing destination policy covers the observed traffic." = "적용 중인 목적지 정책이 관찰된 트래픽을 포괄합니다."; +"Its reporting destination policy covers the observed traffic." = "보고용 목적지 정책이 관찰된 트래픽을 포괄합니다."; +"No agent destination policy is enabled." = "활성화된 에이전트 목적지 정책이 없습니다."; +"One unresolved alert needs review." = "해결되지 않은 알림 1개를 검토해야 합니다."; +"%lld unresolved alerts need review." = "해결되지 않은 알림 %lld개를 검토해야 합니다."; +"Installed, configured, and observed AI agents, with explainable setup and visibility health." = "설치, 구성 또는 관찰된 AI 에이전트와 설정 및 가시성 상태의 근거를 보여 줍니다."; +"No AI agents found" = "AI 에이전트를 찾을 수 없음"; +"Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider." = "Flowlight는 지원되는 앱과 구성된 명령줄 에이전트의 목록을 만들고 알려진 AI 제공업체에 연결하는 비브라우저 클라이언트를 검색합니다."; +"Agents inventoried" = "목록에 있는 에이전트"; +"Active in window" = "기간 내 활동"; +"Health" = "상태"; +"Why" = "이유"; +"Policy" = "정책"; +"Not set" = "설정되지 않음"; +"Enforcing" = "적용 중"; +"Reporting" = "보고만 함"; +"No traffic detail is available for this agent in the selected window." = "선택한 기간에는 이 에이전트의 트래픽 세부 정보가 없습니다."; +"No matching alerts" = "일치하는 알림 없음"; +"Nothing matches this rule" = "이 규칙과 일치하는 항목이 없습니다"; +"State" = "상태"; +"Disposition" = "판정"; +"Acknowledge all new (%lld)" = "새 알림 모두 확인 (%lld)"; +"Triage" = "분류"; +"Update the selected alerts" = "선택한 알림 업데이트"; +"Set State" = "상태 설정"; +"Set Disposition" = "판정 설정"; +"Edit Note…" = "메모 편집…"; +"Triage Note" = "분류 메모"; +"%lld open, no new" = "미해결 %lld개, 새 알림 없음"; +"%lld new of %lld open" = "새 알림 %lld개 / 미해결 %lld개"; +"%lld investigating" = "조사 중 %lld개"; +"%lld resolved" = "해결됨 %lld개"; +"%lld dismissed" = "제외됨 %lld개"; +"No open alerts match the current Focus scope." = "현재 Focus 범위와 일치하는 미해결 알림이 없습니다."; +"No alerts are being investigated in the current Focus scope." = "현재 Focus 범위에서 조사 중인 알림이 없습니다."; +"No resolved alerts match the current Focus scope." = "현재 Focus 범위와 일치하는 해결된 알림이 없습니다."; +"No dismissed alerts match the current Focus scope." = "현재 Focus 범위와 일치하는 제외된 알림이 없습니다."; +"New" = "새 알림"; +"Acknowledged" = "확인됨"; +"Investigating" = "조사 중"; +"Resolved" = "해결됨"; +"Dismissed" = "제외됨"; +"Unset" = "설정되지 않음"; +"True positive" = "정탐"; +"Expected / benign" = "예상됨 / 무해함"; +"False positive" = "오탐"; +"Open" = "미해결"; +"Plugins & Providers" = "플러그인 및 제공업체"; +"Manage inspection plugins and local provider profiles" = "검사 플러그인과 로컬 제공업체 프로필 관리"; +"Live network traffic" = "실시간 네트워크 트래픽"; +"No time selected" = "선택한 시간 없음"; +"Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second." = "채워진 원은 다운로드이고 점선 마름모는 업로드입니다. 왼쪽 및 오른쪽 화살표 키를 사용하여 1초씩 살펴보세요."; +"Download · solid" = "다운로드 · 채움"; +"Upload · dashed" = "업로드 · 점선"; +"Inspection extensions, local routing metadata, and retained findings." = "검사 확장 기능, 로컬 라우팅 메타데이터 및 보관된 결과입니다."; +"No provider profiles" = "제공업체 프로필 없음"; +"Profiles group local provider settings with the inspection plugins that use them." = "프로필은 로컬 제공업체 설정과 이를 사용하는 검사 플러그인을 한데 묶습니다."; +"Add Provider…" = "제공업체 추가…"; +"Provider profiles" = "제공업체 프로필"; +"Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile." = "프로필은 이 Mac에 보관됩니다. 기밀이 아닌 옵션과 선택적 키체인 참조가 포함되며, Flowlight는 자격 증명 값을 프로필에 저장하지 않습니다."; +"Saved provider profile %@." = "제공업체 프로필 %@을(를) 저장했습니다."; +"Remove provider profile?" = "제공업체 프로필을 제거할까요?"; +"Removed provider profile %@." = "제공업체 프로필 %@을(를) 제거했습니다."; +"Could not remove provider profile." = "제공업체 프로필을 제거할 수 없습니다."; +"Plugins and their retained findings stay installed." = "플러그인과 보관된 결과는 설치된 상태로 유지됩니다."; +"Credential value kept in Keychain" = "자격 증명 값은 키체인에 보관됨"; +"Add Provider Profile" = "제공업체 프로필 추가"; +"Edit Provider Profile" = "제공업체 프로필 편집"; +"Identity" = "식별 정보"; +"Enabled" = "활성화됨"; +"Endpoint (optional)" = "엔드포인트(선택 사항)"; +"Keychain reference (optional)" = "키체인 참조(선택 사항)"; +"Use keychain:service/account. The credential value is never stored in this profile." = "keychain:service/account를 사용하세요. 자격 증명 값은 이 프로필에 저장되지 않습니다."; +"No installed plugins use this provider." = "설치된 플러그인 중 이 제공업체를 사용하는 플러그인이 없습니다."; +"Plugin enabled" = "플러그인 활성화됨"; +"Plugin disabled" = "플러그인 비활성화됨"; +"Inspection plugins" = "검사 플러그인"; +"Option" = "옵션"; +"Remove provider option" = "제공업체 옵션 제거"; +"Add Option" = "옵션 추가"; +"Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected." = "옵션은 기밀이 아닌 로컬 메타데이터입니다. API 키, 토큰, 암호 및 기타 자격 증명은 허용되지 않습니다."; +"Provider options" = "제공업체 옵션"; diff --git a/Flowlight/Localization/pt-PT.lproj/Localizable.strings b/Flowlight/Localization/pt-PT.lproj/Localizable.strings index 324de56f..4cf94dba 100644 --- a/Flowlight/Localization/pt-PT.lproj/Localizable.strings +++ b/Flowlight/Localization/pt-PT.lproj/Localizable.strings @@ -395,7 +395,6 @@ /* Inspection/InspectionController.swift */ "AI agents and their tools" = "Os agentes de IA e as suas ferramentas"; "Every app that uses the proxy" = "Todas as apps que usam o proxy"; -"Removed %@" = "Removido %@"; "No network services to send through the proxy, so nothing would be inspected. Check System Settings › Network." = "Não há serviços de rede para encaminhar pelo proxy, por isso nada seria inspecionado. Verifique Definições do Sistema › Rede."; "authorization failed" = "a autorização falhou"; "Couldn't set Flowlight up: %@" = "Não foi possível configurar o Flowlight: %@"; @@ -446,24 +445,11 @@ "Last hour" = "Última hora"; "24 hours" = "24 horas"; "7 days" = "7 dias"; -"Observed AI agent activity, including destinations outside each agent's model provider." = "Atividade observada dos agentes de IA, incluindo destinos fora do fornecedor de modelos de cada agente."; "Window" = "Período"; -"No AI agents seen" = "Nenhum agente de IA visto"; -"Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider." = "O Flowlight reconhece agentes suportados como o Claude Code, o Codex, o Cursor e o Ollama. Também classifica como agentes as apps que não são navegadores e contactam um fornecedor de API de LLM conhecido."; -"Agents active" = "Agentes ativos"; -"AI API traffic" = "Tráfego de API de IA"; -"Uploaded to other hosts" = "Enviado para outros anfitriões"; -"Using sensitive channels" = "A usar canais sensíveis"; -"Agent alerts" = "Alertas de agentes"; "Agent" = "Agente"; "Detected: calls an AI API" = "Detetado: chama uma API de IA"; "AI providers" = "Fornecedores de IA"; -"AI traffic" = "Tráfego de IA"; -"Other hosts" = "Outros anfitriões"; -"Sent elsewhere" = "Enviado para outros"; -"%@ of this agent's uploads went to non-AI hosts" = "%@ dos envios deste agente foram para anfitriões não-IA"; "Allowlist" = "Lista de permissões"; -"Risk" = "Risco"; "Show Traffic in History" = "Ver o tráfego no Histórico"; "None seen" = "Nada visto"; "Large upload" = "Envio grande"; @@ -581,12 +567,6 @@ "No %@ in this window. Flowlight is decrypting %@'s traffic; its model just hasn't asked for any." = "Sem %@ neste período. O Flowlight está a desencriptar o tráfego de %@; o seu modelo simplesmente não pediu nenhuma."; /* UI/AlertsView.swift */ -"No alerts" = "Sem alertas"; -"Nothing to review" = "Nada para rever"; -"Each app has a learning period (24 h by default) before first-contact and spike rules fire." = "Cada app tem um período de aprendizagem (24 h por predefinição) antes de as regras de primeiro contacto e de pico dispararem."; -"All matching alerts have been acknowledged." = "Todos os alertas correspondentes foram confirmados."; -"%lld alerts, all acknowledged" = "%lld alertas, todos confirmados"; -"%lld to review of %lld" = "%lld para rever de %lld"; "Critical" = "Crítico"; "Warning" = "Aviso"; "Info" = "Informação"; @@ -603,13 +583,7 @@ "Acknowledge" = "Confirmar"; "Copy Detail" = "Copiar os detalhes"; "All rules" = "Todas as regras"; -"Show acknowledged" = "Mostrar os confirmados"; "Filter which alerts are listed" = "Filtrar os alertas listados"; -"Selected (%lld)" = "Selecionados (%lld)"; -"All unacknowledged (%lld)" = "Todos os não confirmados (%lld)"; -"Mark alerts as seen" = "Marcar os alertas como vistos"; -"All rules, including seen" = "Todas as regras, incluindo os vistos"; -"%@, including seen" = "%@, incluindo os vistos"; /* UI/AskView.swift */ "%@ · leaves this Mac" = "%@ · sai deste Mac"; @@ -775,8 +749,6 @@ "Seen as it happened, named, and readable." = "Visto no momento em que aconteceu, nomeado e legível."; /* UI/DevicesView.swift */ -"USB" = "USB"; -"Not watching" = "Não vigiado"; "Read the device list again now (⌘R)" = "Ler a lista de dispositivos outra vez agora (⌘R)"; "Watch Bluetooth" = "Vigiar o Bluetooth"; "Watch USB and external storage" = "Vigiar o USB e o armazenamento externo"; @@ -1257,13 +1229,8 @@ "Edit as normal rule…" = "Editar como regra normal…"; /* UI/RulesView.swift */ -"Packs" = "Pacotes"; -"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "As decisões das regras e os bloqueios resumidos dos pacotes aparecem aqui. A atividade dos pacotes é agrupada para que uma página cheia de anúncios não se torne numa página cheia de alertas."; "Managed packs" = "Pacotes geridos"; -"What they did" = "O que fizeram"; "Paused until %@" = "Em pausa até %@"; -"%lld in force" = "%lld em vigor"; -"%1$lld of %2$lld in force" = "%1$lld de %2$lld em vigor"; "Templates" = "Modelos"; "Rules worth having, grouped by what they are for, with the hosts already written out" = "Regras que vale a pena ter, agrupadas pelo seu propósito, com os anfitriões já escritos"; "Write a rule: an app, an agent, a destination or a URL, blocked or allowed" = "Escrever uma regra: uma app, um agente, um destino ou um URL, bloqueado ou permitido"; @@ -1277,8 +1244,6 @@ "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld regras estão a ser vigiadas mas não aplicadas. Só a extensão de rede está no caminho dos dados e pode recusar uma ligação; o amostrador nettop conta o tráfego depois do facto. Selecione a extensão de rede em Origens › Coletor para aplicar estas regras."; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "Uma regra está a ser vigiada mas não aplicada — uma regra que nomeia um caminho só pode ser correspondida pela inspeção HTTPS, e ela está desativada."; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld regras estão a ser vigiadas mas não aplicadas — uma regra que nomeia um caminho só pode ser correspondida pela inspeção HTTPS, e ela está desativada."; -"In force" = "Em vigor"; -"Not in force" = "Sem vigor"; "Nothing is being refused until %@." = "Nada está a ser recusado até %@."; "Resume now" = "Retomar agora"; "No rules yet" = "Ainda sem regras"; @@ -1288,7 +1253,6 @@ "Nothing yet" = "Ainda nada"; "Today" = "Hoje"; "Yesterday" = "Ontem"; -"A rule since deleted" = "Uma regra entretanto eliminada"; "Edit…" = "Editar…"; "Reset Count" = "Reiniciar a contagem"; "Delete" = "Eliminar"; @@ -1886,3 +1850,192 @@ "Restart or reinstall the filter to finish the update" = "Reinicie ou reinstale o filtro para concluir a atualização"; "Return to the previous history scope" = "Voltar ao âmbito anterior do histórico"; "Selected" = "Selecionado"; + +/* 0.17.4 políticas e origens unificadas */ +"Policies" = "Políticas"; +"Activity" = "Atividade"; +"Choose the traffic source and verify that data is arriving." = "Escolha a origem do tráfego e verifique se os dados estão a chegar."; +"Choose which local device channels Flowlight watches." = "Escolha os canais de dispositivos locais que o Flowlight vigia."; +"Check what Flowlight can name, attribute, and inspect." = "Verifique o que o Flowlight consegue nomear, atribuir e inspecionar."; +"Network rules" = "Regras de rede"; +"Agent allowlists" = "Listas de permissões dos agentes"; +"Agent guardrails" = "Proteções dos agentes"; +"Request rewrites" = "Reescritas de pedidos"; +"Response transforms" = "Transformações de respostas"; +"In effect" = "Em vigor"; +"Reporting only" = "Apenas a comunicar"; +"Waiting" = "À espera"; +"Unavailable" = "Indisponível"; +"Not applying" = "Não aplicado"; +"HTTPS inspection is off." = "A inspeção HTTPS está desativada."; +"HTTPS inspection is not running." = "A inspeção HTTPS não está em execução."; +"This policy is switched off." = "Esta política está desativada."; +"This rule is switched off." = "Esta regra está desativada."; +"Network blocking is paused." = "O bloqueio da rede está em pausa."; +"This one-off rule has already been used." = "Esta regra de utilização única já foi usada."; +"This rule is outside its scheduled time." = "Esta regra está fora do horário agendado."; +"Every destination" = "Todos os destinos"; +"No listed destinations" = "Nenhum destino listado"; +"1 listed destination" = "1 destino listado"; +"%lld listed destinations" = "%lld destinos listados"; +"This allowlist is switched off." = "Esta lista de permissões está desativada."; +"Unlisted destinations raise alerts but are not blocked." = "Os destinos não listados geram alertas, mas não são bloqueados."; +"Blocking is unavailable; unlisted destinations still raise alerts." = "O bloqueio não está disponível; os destinos não listados continuam a gerar alertas."; +"Review the changed pack before it can keep applying." = "Reveja o pacote alterado antes de este poder continuar a ser aplicado."; +"This pack is switched off." = "Este pacote está desativado."; +"The pack is matching destinations but cannot block them here." = "O pacote encontra destinos correspondentes, mas não os consegue bloquear aqui."; +"The pack is waiting for the active filter to apply it." = "O pacote aguarda que o filtro ativo o aplique."; +"The managed-pack engine is not applying this configuration." = "O motor de pacotes geridos não está a aplicar esta configuração."; +"No longer in the official catalog" = "Já não consta do catálogo oficial"; +"Blocking stopped automatically. Acknowledge this change after review." = "O bloqueio parou automaticamente. Depois de rever esta alteração, confirme-a."; +"Every agent" = "Todos os agentes"; +"A policy since deleted" = "Uma política entretanto eliminada"; +"%@ · %lld connections" = "%@ · %lld ligações"; +"%lld policies · none currently effective" = "%lld políticas · nenhuma atualmente em vigor"; +"%1$lld policies · %2$lld currently effective" = "%1$lld políticas · %2$lld atualmente em vigor"; +"Everything Flowlight can apply" = "Tudo o que o Flowlight pode aplicar"; +"Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent." = "As decisões de rede, os limites dos agentes, as listas geridas de destinos e as políticas de inspeção para programadores estão aqui. Cada política continua a abrir no editor responsável pelo respetivo comportamento e consentimento."; +"Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent." = "As decisões de rede, os limites dos agentes e as listas geridas de destinos estão aqui. Cada política continua a abrir no editor responsável pelo respetivo comportamento e consentimento."; +"%lld destinations · %@" = "%lld destinos · %@"; +"every app" = "todas as apps"; +"Official · pack v%@ · %@" = "Oficial · pacote v%@ · %@"; +"Flowlight · signer %@" = "Flowlight · signatário %@"; +"Catalog" = "Catálogo"; +"Sequence %1$lld · digest %2$@" = "Sequência %1$lld · resumo %2$@"; +"Last verified" = "Última verificação"; +"Next check" = "Próxima verificação"; +"Official catalog details" = "Detalhes do catálogo oficial"; +"Not checked" = "Não verificado"; +"Verified" = "Verificado"; +"Stale" = "Desatualizado"; +"Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records." = "Bloqueios, exceções, violações de listas de permissões, proteções, simulações e alterações a pedidos ou respostas aparecem aqui sem alterar os registos originais."; +"Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records." = "Bloqueios de rede, exceções, correspondências de pacotes geridos e violações de listas de permissões aparecem aqui sem alterar os registos originais."; +"Blocked connection or request" = "Ligação ou pedido bloqueado"; +"Blocked by a managed pack" = "Bloqueado por um pacote gerido"; +"Outside an agent allowlist" = "Fora da lista de permissões de um agente"; +"Capability removed by a guardrail" = "Capacidade removida por uma proteção"; +"Answered by a mock" = "Respondido por uma simulação"; +"Request changed before sending" = "Pedido alterado antes do envio"; +"Response changed before delivery" = "Resposta alterada antes da entrega"; + +/* 0.17.5 perfis de fornecedores, inventário de agentes e triagem de alertas */ +"Couldn't update alerts: %@" = "Não foi possível atualizar os alertas: %@"; +"Needs attention" = "Requer atenção"; +"Ready" = "Pronto"; +"This provider profile is disabled." = "Este perfil de fornecedor está desativado."; +"Assign at least one inspection plugin." = "Atribua pelo menos um plugin de inspeção."; +"An assigned plugin is no longer installed." = "Um plugin atribuído já não está instalado."; +"An assigned plugin uses a different provider." = "Um plugin atribuído utiliza um fornecedor diferente."; +"Every assigned plugin is disabled." = "Todos os plugins atribuídos estão desativados."; +"1 inspection plugin assigned." = "1 plugin de inspeção atribuído."; +"%lld inspection plugins assigned." = "%lld plugins de inspeção atribuídos."; +"Enter a provider profile name between 1 and 80 characters." = "Introduza um nome de perfil de fornecedor com 1 a 80 caracteres."; +"Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed." = "Utilize um endpoint HTTPS ou HTTP apenas para localhost. Não são permitidas informações de utilizador, parâmetros de consulta nem fragmentos."; +"Use a Keychain reference such as keychain:service/account, not a credential value." = "Utilize uma referência do porta-chaves, como keychain:service/account, e não o valor de uma credencial."; +"Provider options must be short, non-secret key/value metadata." = "As opções do fornecedor devem ser metadados curtos e não confidenciais, no formato chave/valor."; +"An assigned plugin identifier is invalid." = "O identificador de um plugin atribuído é inválido."; +"Provider profiles could not be loaded. Create them again in Inspect." = "Não foi possível carregar os perfis de fornecedores. Volte a criá-los em Inspecionar."; +"Healthy" = "Saudável"; +"Needs setup" = "Requer configuração"; +"Limited visibility" = "Visibilidade limitada"; +"Attention" = "Atenção"; +"Not observed" = "Não observado"; +"Detected agent" = "Agente detetado"; +"Traffic was observed in the selected window." = "Foi observado tráfego no período selecionado."; +"Configuration was found, but no traffic was observed in the selected window." = "Foi encontrada uma configuração, mas não foi observado tráfego no período selecionado."; +"Installed on this Mac, with no traffic in the selected window." = "Instalado neste Mac, sem tráfego no período selecionado."; +"No traffic was observed in the selected window." = "Não foi observado tráfego no período selecionado."; +"Always Monitor is configured, but no current route was observed." = "A opção «Monitorizar sempre» está configurada, mas não foi observada nenhuma rota atual."; +"Its route through Flowlight was observed." = "Foi observada a sua rota através do Flowlight."; +"The saved Flowlight route was not observed in this window." = "A rota guardada do Flowlight não foi observada neste período."; +"Flowlight could not establish the requested route." = "O Flowlight não conseguiu estabelecer a rota solicitada."; +"HTTPS inspection is unavailable in this build; only connection metadata is visible." = "A inspeção HTTPS não está disponível nesta versão; só são visíveis os metadados das ligações."; +"HTTPS inspection is off; request contents are not visible." = "A inspeção HTTPS está desativada; o conteúdo dos pedidos não está visível."; +"The HTTPS inspection certificate is not trusted." = "O certificado de inspeção HTTPS não é fidedigno."; +"HTTPS inspection is enabled, but its proxy is not ready." = "A inspeção HTTPS está ativada, mas o respetivo proxy não está pronto."; +"No inspected requests were observed in this window." = "Não foram observados pedidos inspecionados neste período."; +"HTTPS inspection observed this agent's traffic." = "A inspeção HTTPS observou o tráfego deste agente."; +"One observed destination is outside its policy." = "Um destino observado não está abrangido pela respetiva política."; +"%lld observed destinations are outside its policy." = "%lld destinos observados não estão abrangidos pela respetiva política."; +"Its enforcing destination policy covers the observed traffic." = "A respetiva política de destinos aplicada abrange o tráfego observado."; +"Its reporting destination policy covers the observed traffic." = "A respetiva política de comunicação de destinos abrange o tráfego observado."; +"No agent destination policy is enabled." = "Não está ativada nenhuma política de destinos do agente."; +"One unresolved alert needs review." = "Um alerta por resolver precisa de revisão."; +"%lld unresolved alerts need review." = "%lld alertas por resolver precisam de revisão."; +"Installed, configured, and observed AI agents, with explainable setup and visibility health." = "Agentes de IA instalados, configurados e observados, com explicações sobre a configuração e o estado de visibilidade."; +"No AI agents found" = "Nenhum agente de IA encontrado"; +"Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider." = "O Flowlight inventaria apps suportadas e agentes de linha de comandos configurados, e descobre clientes que não são navegadores e contactam um fornecedor de IA conhecido."; +"Agents inventoried" = "Agentes inventariados"; +"Active in window" = "Ativos no período"; +"Health" = "Estado"; +"Why" = "Motivo"; +"Policy" = "Política"; +"Not set" = "Não definida"; +"Enforcing" = "A aplicar"; +"Reporting" = "Apenas a comunicar"; +"No traffic detail is available for this agent in the selected window." = "Não estão disponíveis detalhes de tráfego deste agente no período selecionado."; +"No matching alerts" = "Nenhum alerta correspondente"; +"Nothing matches this rule" = "Nada corresponde a esta regra"; +"State" = "Estado"; +"Disposition" = "Classificação"; +"Acknowledge all new (%lld)" = "Confirmar todos os novos (%lld)"; +"Triage" = "Triagem"; +"Update the selected alerts" = "Atualizar os alertas selecionados"; +"Set State" = "Definir estado"; +"Set Disposition" = "Definir classificação"; +"Edit Note…" = "Editar nota…"; +"Triage Note" = "Nota de triagem"; +"%lld open, no new" = "%lld abertos, nenhum novo"; +"%lld new of %lld open" = "%lld novos de %lld abertos"; +"%lld investigating" = "%lld em investigação"; +"%lld resolved" = "%lld resolvidos"; +"%lld dismissed" = "%lld descartados"; +"No open alerts match the current Focus scope." = "Nenhum alerta aberto corresponde ao âmbito atual do Foco."; +"No alerts are being investigated in the current Focus scope." = "Não há alertas em investigação no âmbito atual do Foco."; +"No resolved alerts match the current Focus scope." = "Nenhum alerta resolvido corresponde ao âmbito atual do Foco."; +"No dismissed alerts match the current Focus scope." = "Nenhum alerta descartado corresponde ao âmbito atual do Foco."; +"New" = "Novo"; +"Acknowledged" = "Confirmado"; +"Investigating" = "Em investigação"; +"Resolved" = "Resolvido"; +"Dismissed" = "Descartado"; +"Unset" = "Não definido"; +"True positive" = "Positivo verdadeiro"; +"Expected / benign" = "Esperado / benigno"; +"False positive" = "Falso positivo"; +"Open" = "Aberto"; +"Plugins & Providers" = "Plugins e fornecedores"; +"Manage inspection plugins and local provider profiles" = "Gerir plugins de inspeção e perfis de fornecedores locais"; +"Live network traffic" = "Tráfego de rede em direto"; +"No time selected" = "Nenhum momento selecionado"; +"Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second." = "Os círculos preenchidos são transferências recebidas; os losangos tracejados são envios. Utilize as teclas de seta para a esquerda e para a direita para inspecionar cada segundo."; +"Download · solid" = "Transferência recebida · círculo preenchido"; +"Upload · dashed" = "Envio · losango tracejado"; +"Inspection extensions, local routing metadata, and retained findings." = "Extensões de inspeção, metadados de encaminhamento local e resultados retidos."; +"No provider profiles" = "Sem perfis de fornecedores"; +"Profiles group local provider settings with the inspection plugins that use them." = "Os perfis agrupam as definições locais dos fornecedores com os plugins de inspeção que as utilizam."; +"Add Provider…" = "Adicionar fornecedor…"; +"Provider profiles" = "Perfis de fornecedores"; +"Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile." = "Os perfis permanecem neste Mac. Contêm opções não confidenciais e referências opcionais do porta-chaves; o Flowlight nunca guarda valores de credenciais num perfil."; +"Saved provider profile %@." = "Perfil de fornecedor %@ guardado."; +"Remove provider profile?" = "Remover o perfil de fornecedor?"; +"Removed provider profile %@." = "Perfil de fornecedor %@ removido."; +"Could not remove provider profile." = "Não foi possível remover o perfil de fornecedor."; +"Plugins and their retained findings stay installed." = "Os plugins e os respetivos resultados retidos permanecem instalados."; +"Credential value kept in Keychain" = "Valor da credencial guardado no porta-chaves"; +"Add Provider Profile" = "Adicionar perfil de fornecedor"; +"Edit Provider Profile" = "Editar perfil de fornecedor"; +"Identity" = "Identidade"; +"Enabled" = "Ativado"; +"Endpoint (optional)" = "Endpoint (opcional)"; +"Keychain reference (optional)" = "Referência do porta-chaves (opcional)"; +"Use keychain:service/account. The credential value is never stored in this profile." = "Utilize keychain:service/account. O valor da credencial nunca é guardado neste perfil."; +"No installed plugins use this provider." = "Nenhum plugin instalado utiliza este fornecedor."; +"Plugin enabled" = "Plugin ativado"; +"Plugin disabled" = "Plugin desativado"; +"Inspection plugins" = "Plugins de inspeção"; +"Option" = "Opção"; +"Remove provider option" = "Remover opção do fornecedor"; +"Add Option" = "Adicionar opção"; +"Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected." = "As opções são metadados locais não confidenciais. Chaves de API, tokens, palavras-passe e outras credenciais são rejeitados."; +"Provider options" = "Opções do fornecedor"; diff --git a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings index b0dafb5f..ac7032b2 100644 --- a/Flowlight/Localization/zh-Hans.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hans.lproj/Localizable.strings @@ -395,7 +395,6 @@ /* Inspection/InspectionController.swift */ "AI agents and their tools" = "AI 代理及其工具"; "Every app that uses the proxy" = "所有使用代理服务器的应用"; -"Removed %@" = "已移除 %@"; "No network services to send through the proxy, so nothing would be inspected. Check System Settings › Network." = "没有可经由代理服务器的网络服务,因此不会检查任何内容。请查看“系统设置”›“网络”。"; "authorization failed" = "授权失败"; "Couldn't set Flowlight up: %@" = "无法完成 Flowlight 的设置:%@"; @@ -446,24 +445,11 @@ "Last hour" = "最近一小时"; "24 hours" = "24 小时"; "7 days" = "7 天"; -"Observed AI agent activity, including destinations outside each agent's model provider." = "观察到的 AI 代理活动,包括各代理的模型提供方之外的目标。"; "Window" = "时间窗口"; -"No AI agents seen" = "未发现 AI 代理"; -"Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider." = "Flowlight 可识别 Claude Code、Codex、Cursor 和 Ollama 等受支持的代理,也会把连接已知 LLM API 提供方的非浏览器应用归为代理。"; -"Agents active" = "活动中的代理"; -"AI API traffic" = "AI API 流量"; -"Uploaded to other hosts" = "上传至其他主机"; -"Using sensitive channels" = "使用敏感通道"; -"Agent alerts" = "代理警报"; "Agent" = "代理"; "Detected: calls an AI API" = "检测到:调用 AI API"; "AI providers" = "AI 提供方"; -"AI traffic" = "AI 流量"; -"Other hosts" = "其他主机"; -"Sent elsewhere" = "发往别处"; -"%@ of this agent's uploads went to non-AI hosts" = "该代理上传量的 %@ 发往了非 AI 主机"; "Allowlist" = "允许列表"; -"Risk" = "风险"; "Show Traffic in History" = "在历史记录中查看流量"; "None seen" = "未发现"; "Large upload" = "大量上传"; @@ -581,12 +567,6 @@ "No %@ in this window. Flowlight is decrypting %@'s traffic; its model just hasn't asked for any." = "此时间窗口内没有%@。Flowlight 正在解密 %@ 的流量;只是它的模型没有请求过。"; /* UI/AlertsView.swift */ -"No alerts" = "没有警报"; -"Nothing to review" = "没有需要查看的内容"; -"Each app has a learning period (24 h by default) before first-contact and spike rules fire." = "每个应用都有一段学习期(默认 24 小时),之后首次连接和激增规则才会触发。"; -"All matching alerts have been acknowledged." = "所有符合条件的警报都已确认。"; -"%lld alerts, all acknowledged" = "%lld 条警报,全部已确认"; -"%lld to review of %lld" = "%lld / %lld 条待查看"; "Critical" = "严重"; "Warning" = "警告"; "Info" = "信息"; @@ -603,13 +583,7 @@ "Acknowledge" = "确认"; "Copy Detail" = "拷贝详情"; "All rules" = "所有规则"; -"Show acknowledged" = "显示已确认"; "Filter which alerts are listed" = "筛选列出哪些警报"; -"Selected (%lld)" = "已选择(%lld)"; -"All unacknowledged (%lld)" = "所有未确认的(%lld)"; -"Mark alerts as seen" = "将警报标记为已读"; -"All rules, including seen" = "所有规则,包括已读"; -"%@, including seen" = "%@,包括已读"; /* UI/AskView.swift */ "%@ · leaves this Mac" = "%@ · 会离开这台 Mac"; @@ -775,8 +749,6 @@ "Seen as it happened, named, and readable." = "在发生时即被看到,有名称,内容也可读。"; /* UI/DevicesView.swift */ -"USB" = "USB"; -"Not watching" = "未监视"; "Read the device list again now (⌘R)" = "立即重新读取设备列表(⌘R)"; "Watch Bluetooth" = "监视蓝牙"; "Watch USB and external storage" = "监视 USB 和外置存储"; @@ -1257,13 +1229,8 @@ "Edit as normal rule…" = "作为普通规则编辑…"; /* UI/RulesView.swift */ -"Packs" = "包"; -"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "规则判定和包的阻止摘要会显示在这里。包活动会被分组,以免满是广告的页面变成满是警报的页面。"; "Managed packs" = "托管包"; -"What they did" = "它们做了什么"; "Paused until %@" = "已暂停至 %@"; -"%lld in force" = "%lld 条生效中"; -"%1$lld of %2$lld in force" = "%2$lld 条中有 %1$lld 条生效中"; "Templates" = "模板"; "Rules worth having, grouped by what they are for, with the hosts already written out" = "值得拥有的规则,按用途分组,主机已经写好"; "Write a rule: an app, an agent, a destination or a URL, blocked or allowed" = "写一条规则:应用、代理、目标或 URL,阻止或允许"; @@ -1277,8 +1244,6 @@ "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "%lld 条规则只是被监视,而没有被执行。只有网络扩展位于数据路径上,能够拒绝连接;nettop 采样器是事后计数。请在“来源 › 收集器”中选择网络扩展,以执行这些规则。"; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "有一条规则只是被监视,而没有被执行 —— 指明路径的规则只能由 HTTPS 检查匹配,而它已关闭。"; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "%lld 条规则只是被监视,而没有被执行 —— 指明路径的规则只能由 HTTPS 检查匹配,而它已关闭。"; -"In force" = "生效中"; -"Not in force" = "未生效"; "Nothing is being refused until %@." = "在 %@ 之前不会拒绝任何连接。"; "Resume now" = "立即恢复"; "No rules yet" = "还没有规则"; @@ -1288,7 +1253,6 @@ "Nothing yet" = "暂无内容"; "Today" = "今天"; "Yesterday" = "昨天"; -"A rule since deleted" = "一条已被删除的规则"; "Edit…" = "编辑…"; "Reset Count" = "重置计数"; "Delete" = "删除"; @@ -1886,3 +1850,192 @@ "Restart or reinstall the filter to finish the update" = "重新启动或重新安装过滤器以完成更新"; "Return to the previous history scope" = "返回上一个历史记录范围"; "Selected" = "已选择"; + +/* 0.17.4 策略与整合后的来源 */ +"Policies" = "策略"; +"Activity" = "活动"; +"Choose the traffic source and verify that data is arriving." = "选择流量来源,并确认数据正在到达。"; +"Choose which local device channels Flowlight watches." = "选择 Flowlight 要监视的本地设备通道。"; +"Check what Flowlight can name, attribute, and inspect." = "检查 Flowlight 能够命名、归属和检查哪些内容。"; +"Network rules" = "网络规则"; +"Agent allowlists" = "代理允许列表"; +"Agent guardrails" = "代理护栏"; +"Request rewrites" = "请求改写"; +"Response transforms" = "响应转换"; +"In effect" = "生效中"; +"Reporting only" = "仅报告"; +"Waiting" = "等待中"; +"Unavailable" = "不可用"; +"Not applying" = "未在执行"; +"HTTPS inspection is off." = "HTTPS 检查已关闭。"; +"HTTPS inspection is not running." = "HTTPS 检查未在运行。"; +"This policy is switched off." = "此策略已关闭。"; +"This rule is switched off." = "此规则已关闭。"; +"Network blocking is paused." = "网络阻止已暂停。"; +"This one-off rule has already been used." = "这条一次性规则已经用过。"; +"This rule is outside its scheduled time." = "此规则当前不在预定时段内。"; +"Every destination" = "所有目标"; +"No listed destinations" = "没有列出的目标"; +"1 listed destination" = "列出了 1 个目标"; +"%lld listed destinations" = "列出了 %lld 个目标"; +"This allowlist is switched off." = "此允许列表已关闭。"; +"Unlisted destinations raise alerts but are not blocked." = "未列出的目标会触发警报,但不会被阻止。"; +"Blocking is unavailable; unlisted destinations still raise alerts." = "阻止功能不可用;未列出的目标仍会触发警报。"; +"Review the changed pack before it can keep applying." = "请先检查已更改的包,之后它才能继续生效。"; +"This pack is switched off." = "此包已关闭。"; +"The pack is matching destinations but cannot block them here." = "此包正在匹配目标,但无法在这里阻止它们。"; +"The pack is waiting for the active filter to apply it." = "此包正在等待活动的过滤器应用它。"; +"The managed-pack engine is not applying this configuration." = "托管包引擎没有执行此配置。"; +"No longer in the official catalog" = "已不在官方目录中"; +"Blocking stopped automatically. Acknowledge this change after review." = "阻止已自动停止。检查后请确认此项变更。"; +"Every agent" = "所有代理"; +"A policy since deleted" = "一项已删除的策略"; +"%@ · %lld connections" = "%@ · %lld 个连接"; +"%lld policies · none currently effective" = "%lld 项策略 · 当前均未生效"; +"%1$lld policies · %2$lld currently effective" = "%1$lld 项策略 · 当前 %2$lld 项生效"; +"Everything Flowlight can apply" = "Flowlight 可以执行的所有策略"; +"Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent." = "网络判定、代理限制、托管目标列表和开发者检查策略都集中在这里。每项策略仍会在负责其行为与授权的编辑器中打开。"; +"Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent." = "网络判定、代理限制和托管目标列表都集中在这里。每项策略仍会在负责其行为与授权的编辑器中打开。"; +"%lld destinations · %@" = "%lld 个目标 · %@"; +"every app" = "所有应用"; +"Official · pack v%@ · %@" = "官方 · 包 v%@ · %@"; +"Flowlight · signer %@" = "Flowlight · 签名者 %@"; +"Catalog" = "目录"; +"Sequence %1$lld · digest %2$@" = "序列 %1$lld · 摘要 %2$@"; +"Last verified" = "上次验证"; +"Next check" = "下次检查"; +"Official catalog details" = "官方目录详情"; +"Not checked" = "未检查"; +"Verified" = "已验证"; +"Stale" = "已过期"; +"Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records." = "阻止、例外、允许列表违规、护栏、模拟响应以及请求或响应改动都会显示在这里,原始记录不会被更改。"; +"Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records." = "网络阻止、例外、托管包匹配和允许列表违规都会显示在这里,原始记录不会被更改。"; +"Blocked connection or request" = "连接或请求已被阻止"; +"Blocked by a managed pack" = "被托管包阻止"; +"Outside an agent allowlist" = "不在代理的允许列表中"; +"Capability removed by a guardrail" = "能力已被护栏移除"; +"Answered by a mock" = "由模拟响应作答"; +"Request changed before sending" = "请求在发送前已更改"; +"Response changed before delivery" = "响应在送达前已更改"; + +/* 0.17.5 提供方配置文件、代理清单与警报分诊 */ +"Couldn't update alerts: %@" = "无法更新警报:%@"; +"Needs attention" = "需要注意"; +"Ready" = "就绪"; +"This provider profile is disabled." = "此提供方配置文件已停用。"; +"Assign at least one inspection plugin." = "请至少分配一个检查插件。"; +"An assigned plugin is no longer installed." = "已分配的某个插件不再处于安装状态。"; +"An assigned plugin uses a different provider." = "已分配的某个插件使用了其他提供方。"; +"Every assigned plugin is disabled." = "所有已分配插件均已停用。"; +"1 inspection plugin assigned." = "已分配 1 个检查插件。"; +"%lld inspection plugins assigned." = "已分配 %lld 个检查插件。"; +"Enter a provider profile name between 1 and 80 characters." = "请输入 1 至 80 个字符的提供方配置文件名称。"; +"Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed." = "请使用 HTTPS 端点;仅 localhost 可使用 HTTP。不允许包含用户信息、查询参数和片段。"; +"Use a Keychain reference such as keychain:service/account, not a credential value." = "请使用 keychain:service/account 这样的钥匙串引用,而不是凭据值。"; +"Provider options must be short, non-secret key/value metadata." = "提供方选项必须是简短的非机密键值元数据。"; +"An assigned plugin identifier is invalid." = "已分配的某个插件标识符无效。"; +"Provider profiles could not be loaded. Create them again in Inspect." = "无法加载提供方配置文件。请在“检查”中重新创建。"; +"Healthy" = "状态良好"; +"Needs setup" = "需要设置"; +"Limited visibility" = "可见性受限"; +"Attention" = "注意"; +"Not observed" = "未观察到"; +"Detected agent" = "检测到的代理"; +"Traffic was observed in the selected window." = "在所选时间窗口内观察到流量。"; +"Configuration was found, but no traffic was observed in the selected window." = "已发现配置,但在所选时间窗口内未观察到流量。"; +"Installed on this Mac, with no traffic in the selected window." = "已安装在这台 Mac 上,但所选时间窗口内没有流量。"; +"No traffic was observed in the selected window." = "在所选时间窗口内未观察到流量。"; +"Always Monitor is configured, but no current route was observed." = "已配置“始终监控”,但未观察到当前路由。"; +"Its route through Flowlight was observed." = "观察到其流量通过 Flowlight 的路由。"; +"The saved Flowlight route was not observed in this window." = "未在此时间窗口内观察到已保存的 Flowlight 路由。"; +"Flowlight could not establish the requested route." = "Flowlight 无法建立所请求的路由。"; +"HTTPS inspection is unavailable in this build; only connection metadata is visible." = "此版本不支持 HTTPS 检查;只能看到连接元数据。"; +"HTTPS inspection is off; request contents are not visible." = "HTTPS 检查已关闭;请求内容不可见。"; +"The HTTPS inspection certificate is not trusted." = "HTTPS 检查证书不受信任。"; +"HTTPS inspection is enabled, but its proxy is not ready." = "HTTPS 检查已启用,但其代理服务器尚未就绪。"; +"No inspected requests were observed in this window." = "此时间窗口内未观察到受检查的请求。"; +"HTTPS inspection observed this agent's traffic." = "HTTPS 检查观察到此代理的流量。"; +"One observed destination is outside its policy." = "有一个观察到的目标不在其策略范围内。"; +"%lld observed destinations are outside its policy." = "有 %lld 个观察到的目标不在其策略范围内。"; +"Its enforcing destination policy covers the observed traffic." = "其强制执行的目标策略涵盖了观察到的流量。"; +"Its reporting destination policy covers the observed traffic." = "其仅报告的目标策略涵盖了观察到的流量。"; +"No agent destination policy is enabled." = "未启用代理目标策略。"; +"One unresolved alert needs review." = "有一个未解决的警报需要查看。"; +"%lld unresolved alerts need review." = "有 %lld 个未解决的警报需要查看。"; +"Installed, configured, and observed AI agents, with explainable setup and visibility health." = "已安装、已配置和已观察到的 AI 代理,并说明设置和可见性状态。"; +"No AI agents found" = "未发现 AI 代理"; +"Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider." = "Flowlight 会盘点受支持的应用和已配置的命令行代理,并发现会连接已知 AI 提供方的非浏览器客户端。"; +"Agents inventoried" = "已盘点代理"; +"Active in window" = "时间窗口内活跃"; +"Health" = "健康状况"; +"Why" = "原因"; +"Policy" = "策略"; +"Not set" = "未设置"; +"Enforcing" = "强制执行"; +"Reporting" = "仅报告"; +"No traffic detail is available for this agent in the selected window." = "所选时间窗口内没有此代理的流量详情。"; +"No matching alerts" = "无匹配警报"; +"Nothing matches this rule" = "此规则无匹配项"; +"State" = "状态"; +"Disposition" = "处置"; +"Acknowledge all new (%lld)" = "确认所有新警报(%lld)"; +"Triage" = "分诊"; +"Update the selected alerts" = "更新所选警报"; +"Set State" = "设置状态"; +"Set Disposition" = "设置处置"; +"Edit Note…" = "编辑备注…"; +"Triage Note" = "分诊备注"; +"%lld open, no new" = "%lld 个未结,无新增"; +"%lld new of %lld open" = "%lld 个新增,共 %lld 个未结"; +"%lld investigating" = "%lld 个调查中"; +"%lld resolved" = "%lld 个已解决"; +"%lld dismissed" = "%lld 个已忽略"; +"No open alerts match the current Focus scope." = "当前聚焦范围内没有匹配的未结警报。"; +"No alerts are being investigated in the current Focus scope." = "当前聚焦范围内没有正在调查的警报。"; +"No resolved alerts match the current Focus scope." = "当前聚焦范围内没有匹配的已解决警报。"; +"No dismissed alerts match the current Focus scope." = "当前聚焦范围内没有匹配的已忽略警报。"; +"New" = "新增"; +"Acknowledged" = "已确认"; +"Investigating" = "调查中"; +"Resolved" = "已解决"; +"Dismissed" = "已忽略"; +"Unset" = "未设置"; +"True positive" = "真阳性"; +"Expected / benign" = "预期行为 / 无害"; +"False positive" = "假阳性"; +"Open" = "未结"; +"Plugins & Providers" = "插件与提供方"; +"Manage inspection plugins and local provider profiles" = "管理检查插件和本地提供方配置文件"; +"Live network traffic" = "实时网络流量"; +"No time selected" = "未选择时间"; +"Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second." = "实心圆表示下载;虚线菱形表示上传。使用左、右箭头键逐秒检查。"; +"Download · solid" = "下载 · 实心"; +"Upload · dashed" = "上传 · 虚线"; +"Inspection extensions, local routing metadata, and retained findings." = "检查扩展、本地路由元数据和保留的发现结果。"; +"No provider profiles" = "无提供方配置文件"; +"Profiles group local provider settings with the inspection plugins that use them." = "配置文件将本地提供方设置与使用它们的检查插件归为一组。"; +"Add Provider…" = "添加提供方…"; +"Provider profiles" = "提供方配置文件"; +"Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile." = "配置文件仅保留在此 Mac 上。其中包含非机密选项和可选的钥匙串引用;Flowlight 绝不会在配置文件中存储凭据值。"; +"Saved provider profile %@." = "已保存提供方配置文件 %@。"; +"Remove provider profile?" = "移除提供方配置文件?"; +"Removed provider profile %@." = "已移除提供方配置文件 %@。"; +"Could not remove provider profile." = "无法移除提供方配置文件。"; +"Plugins and their retained findings stay installed." = "插件及其保留的发现结果仍处于安装状态。"; +"Credential value kept in Keychain" = "凭据值保存在钥匙串中"; +"Add Provider Profile" = "添加提供方配置文件"; +"Edit Provider Profile" = "编辑提供方配置文件"; +"Identity" = "标识"; +"Enabled" = "已启用"; +"Endpoint (optional)" = "端点(可选)"; +"Keychain reference (optional)" = "钥匙串引用(可选)"; +"Use keychain:service/account. The credential value is never stored in this profile." = "请使用 keychain:service/account。凭据值绝不会存储在此配置文件中。"; +"No installed plugins use this provider." = "没有已安装的插件使用此提供方。"; +"Plugin enabled" = "插件已启用"; +"Plugin disabled" = "插件已停用"; +"Inspection plugins" = "检查插件"; +"Option" = "选项"; +"Remove provider option" = "移除提供方选项"; +"Add Option" = "添加选项"; +"Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected." = "选项是本地非机密元数据。API 密钥、令牌、密码及其他凭据会被拒绝。"; +"Provider options" = "提供方选项"; diff --git a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings index 9fb87054..7d8f2467 100644 --- a/Flowlight/Localization/zh-Hant.lproj/Localizable.strings +++ b/Flowlight/Localization/zh-Hant.lproj/Localizable.strings @@ -395,7 +395,6 @@ /* Inspection/InspectionController.swift */ "AI agents and their tools" = "AI 代理及其工具"; "Every app that uses the proxy" = "所有使用代理伺服器的 App"; -"Removed %@" = "已移除 %@"; "No network services to send through the proxy, so nothing would be inspected. Check System Settings › Network." = "沒有可以導向代理伺服器的網路服務,因此不會檢查任何東西。請查看「系統設定」›「網路」。"; "authorization failed" = "授權失敗"; "Couldn't set Flowlight up: %@" = "無法設定 Flowlight:%@"; @@ -446,24 +445,11 @@ "Last hour" = "最近一小時"; "24 hours" = "24 小時"; "7 days" = "7 天"; -"Observed AI agent activity, including destinations outside each agent's model provider." = "觀察到的 AI 代理活動,包含各代理在其模型供應商之外連線的目標。"; "Window" = "時間範圍"; -"No AI agents seen" = "未看到 AI 代理"; -"Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider." = "Flowlight 能辨識 Claude Code、Codex、Cursor 和 Ollama 等支援的代理,也會把連線到已知 LLM API 供應商的非瀏覽器 App 歸類為代理。"; -"Agents active" = "活動中的代理"; -"AI API traffic" = "AI API 流量"; -"Uploaded to other hosts" = "上傳到其他主機"; -"Using sensitive channels" = "使用敏感通道"; -"Agent alerts" = "代理警示"; "Agent" = "代理"; "Detected: calls an AI API" = "偵測結果:呼叫了 AI API"; "AI providers" = "AI 供應商"; -"AI traffic" = "AI 流量"; -"Other hosts" = "其他主機"; -"Sent elsewhere" = "傳送到別處"; -"%@ of this agent's uploads went to non-AI hosts" = "這個代理有 %@ 的上傳量送到了非 AI 主機"; "Allowlist" = "允許清單"; -"Risk" = "風險"; "Show Traffic in History" = "在「歷史記錄」中顯示流量"; "None seen" = "未看到任何項目"; "Large upload" = "大量上傳"; @@ -581,12 +567,6 @@ "No %@ in this window. Flowlight is decrypting %@'s traffic; its model just hasn't asked for any." = "此時間範圍內沒有 %@。Flowlight 正在解密 %@ 的流量;只是它的模型沒有要求過。"; /* UI/AlertsView.swift */ -"No alerts" = "沒有警示"; -"Nothing to review" = "沒有待檢視的項目"; -"Each app has a learning period (24 h by default) before first-contact and spike rules fire." = "每個 App 都有一段學習期(預設 24 小時),之後首次連線和流量突增的規則才會觸發。"; -"All matching alerts have been acknowledged." = "所有符合條件的警示都已確認。"; -"%lld alerts, all acknowledged" = "%lld 則警示,全部已確認"; -"%lld to review of %lld" = "%lld 則待檢視,共 %lld 則"; "Critical" = "嚴重"; "Warning" = "警告"; "Info" = "資訊"; @@ -603,13 +583,7 @@ "Acknowledge" = "確認"; "Copy Detail" = "拷貝詳細資料"; "All rules" = "所有規則"; -"Show acknowledged" = "顯示已確認的項目"; "Filter which alerts are listed" = "篩選要列出哪些警示"; -"Selected (%lld)" = "已選取(%lld)"; -"All unacknowledged (%lld)" = "所有未確認的(%lld)"; -"Mark alerts as seen" = "將警示標示為已看過"; -"All rules, including seen" = "所有規則,包含已看過的"; -"%@, including seen" = "%@,包含已看過的"; /* UI/AskView.swift */ "%@ · leaves this Mac" = "%@ · 會離開這台 Mac"; @@ -775,8 +749,6 @@ "Seen as it happened, named, and readable." = "在發生時就看到、有名稱,內容也讀得到。"; /* UI/DevicesView.swift */ -"USB" = "USB"; -"Not watching" = "未監看"; "Read the device list again now (⌘R)" = "立即重新讀取裝置列表(⌘R)"; "Watch Bluetooth" = "監看藍牙"; "Watch USB and external storage" = "監看 USB 與外接儲存裝置"; @@ -1257,13 +1229,8 @@ "Edit as normal rule…" = "編輯為一般規則…"; /* UI/RulesView.swift */ -"Packs" = "套件"; -"Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts." = "規則決定和套件的封鎖摘要會顯示在這裡。套件活動會被分組,以免滿是廣告的頁面變成滿是警示的頁面。"; "Managed packs" = "受管理套件"; -"What they did" = "它們做了什麼"; "Paused until %@" = "已暫停至 %@"; -"%lld in force" = "%lld 條生效中"; -"%1$lld of %2$lld in force" = "%2$lld 條中有 %1$lld 條生效中"; "Templates" = "範本"; "Rules worth having, grouped by what they are for, with the hosts already written out" = "值得一用的規則,依用途分組,主機都已經寫好"; "Write a rule: an app, an agent, a destination or a URL, blocked or allowed" = "寫一條規則:指定 App、代理、目標或 URL,並選擇封鎖或允許"; @@ -1277,8 +1244,6 @@ "%lld rules are being watched but not carried out. Only the Network Extension sits in the data path and can refuse a connection; the nettop sampler counts traffic after the fact. Select the Network Extension in Sources › Collector to enforce these rules." = "有 %lld 條規則只被監看,沒有實際執行。只有網路延伸功能位在資料路徑上,能夠拒絕連線;nettop 取樣器是事後計數流量的。請在「來源 › 收集器」中選擇網路延伸功能來實際執行這些規則。"; "One rule is being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "有 1 條規則只被監看,沒有實際執行 — 指名路徑的規則只有 HTTPS 檢查才比對得到,而它已關閉。"; "%lld rules are being watched but not carried out — a rule that names a path can only be matched by HTTPS inspection, and it is off." = "有 %lld 條規則只被監看,沒有實際執行 — 指名路徑的規則只有 HTTPS 檢查才比對得到,而它已關閉。"; -"In force" = "生效中"; -"Not in force" = "未生效"; "Nothing is being refused until %@." = "在 %@ 之前不會拒絕任何連線。"; "Resume now" = "立即恢復"; "No rules yet" = "還沒有任何規則"; @@ -1288,7 +1253,6 @@ "Nothing yet" = "還沒有任何項目"; "Today" = "今天"; "Yesterday" = "昨天"; -"A rule since deleted" = "一條之後被刪除的規則"; "Edit…" = "編輯…"; "Reset Count" = "重設計數"; "Delete" = "刪除"; @@ -1886,3 +1850,192 @@ "Restart or reinstall the filter to finish the update" = "重新啟動或重新安裝過濾器以完成更新"; "Return to the previous history scope" = "返回上一個歷史記錄範圍"; "Selected" = "已選取"; + +/* 0.17.4 政策與整合後的來源 */ +"Policies" = "政策"; +"Activity" = "活動"; +"Choose the traffic source and verify that data is arriving." = "選擇流量來源,並確認資料正在傳入。"; +"Choose which local device channels Flowlight watches." = "選擇 Flowlight 要監看的本機裝置通道。"; +"Check what Flowlight can name, attribute, and inspect." = "檢查 Flowlight 能為哪些內容命名、判定歸屬和進行檢查。"; +"Network rules" = "網路規則"; +"Agent allowlists" = "代理允許清單"; +"Agent guardrails" = "代理防護機制"; +"Request rewrites" = "請求改寫"; +"Response transforms" = "回應轉換"; +"In effect" = "生效中"; +"Reporting only" = "僅回報"; +"Waiting" = "等待中"; +"Unavailable" = "無法使用"; +"Not applying" = "未套用"; +"HTTPS inspection is off." = "HTTPS 檢查已關閉。"; +"HTTPS inspection is not running." = "HTTPS 檢查未在執行。"; +"This policy is switched off." = "此政策已關閉。"; +"This rule is switched off." = "此規則已關閉。"; +"Network blocking is paused." = "網路封鎖已暫停。"; +"This one-off rule has already been used." = "這條一次性規則已經用過。"; +"This rule is outside its scheduled time." = "此規則目前不在排定時段內。"; +"Every destination" = "所有目標"; +"No listed destinations" = "沒有列出的目標"; +"1 listed destination" = "列出 1 個目標"; +"%lld listed destinations" = "列出 %lld 個目標"; +"This allowlist is switched off." = "此允許清單已關閉。"; +"Unlisted destinations raise alerts but are not blocked." = "未列出的目標會發出警示,但不會被封鎖。"; +"Blocking is unavailable; unlisted destinations still raise alerts." = "封鎖功能無法使用;未列出的目標仍會發出警示。"; +"Review the changed pack before it can keep applying." = "請先檢閱變更後的套件,它才能繼續套用。"; +"This pack is switched off." = "此套件已關閉。"; +"The pack is matching destinations but cannot block them here." = "此套件正在比對目標,但無法在這裡封鎖它們。"; +"The pack is waiting for the active filter to apply it." = "此套件正在等待作用中的過濾器套用它。"; +"The managed-pack engine is not applying this configuration." = "受管理套件引擎沒有套用這項設定。"; +"No longer in the official catalog" = "已不在官方目錄中"; +"Blocking stopped automatically. Acknowledge this change after review." = "封鎖已自動停止。檢閱後請確認這項變更。"; +"Every agent" = "所有代理"; +"A policy since deleted" = "一項已刪除的政策"; +"%@ · %lld connections" = "%@ · %lld 個連線"; +"%lld policies · none currently effective" = "%lld 項政策 · 目前均未生效"; +"%1$lld policies · %2$lld currently effective" = "%1$lld 項政策 · 目前 %2$lld 項生效"; +"Everything Flowlight can apply" = "Flowlight 可以套用的所有政策"; +"Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent." = "網路判定、代理限制、受管理目標清單和開發者檢查政策都集中在這裡。每項政策仍會在負責其行為與授權的編輯器中開啟。"; +"Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent." = "網路判定、代理限制和受管理目標清單都集中在這裡。每項政策仍會在負責其行為與授權的編輯器中開啟。"; +"%lld destinations · %@" = "%lld 個目標 · %@"; +"every app" = "所有 App"; +"Official · pack v%@ · %@" = "官方 · 套件 v%@ · %@"; +"Flowlight · signer %@" = "Flowlight · 簽署者 %@"; +"Catalog" = "目錄"; +"Sequence %1$lld · digest %2$@" = "序號 %1$lld · 摘要 %2$@"; +"Last verified" = "上次驗證"; +"Next check" = "下次檢查"; +"Official catalog details" = "官方目錄詳細資料"; +"Not checked" = "尚未檢查"; +"Verified" = "已驗證"; +"Stale" = "已過期"; +"Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records." = "封鎖、例外、允許清單違規、防護機制、模擬回應,以及請求或回應變更都會顯示在這裡,而不會改動原始記錄。"; +"Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records." = "網路封鎖、例外、受管理套件比對和允許清單違規都會顯示在這裡,而不會改動原始記錄。"; +"Blocked connection or request" = "連線或請求已被封鎖"; +"Blocked by a managed pack" = "被受管理套件封鎖"; +"Outside an agent allowlist" = "不在代理的允許清單中"; +"Capability removed by a guardrail" = "功能已被防護機制移除"; +"Answered by a mock" = "由模擬回應作答"; +"Request changed before sending" = "請求在送出前已變更"; +"Response changed before delivery" = "回應在送達前已變更"; + +/* 0.17.5 供應商設定檔、代理清單與警示分類 */ +"Couldn't update alerts: %@" = "無法更新警示:%@"; +"Needs attention" = "需要注意"; +"Ready" = "就緒"; +"This provider profile is disabled." = "此供應商設定檔已停用。"; +"Assign at least one inspection plugin." = "請至少指派一個檢查外掛程式。"; +"An assigned plugin is no longer installed." = "已指派的某個外掛程式已不再安裝。"; +"An assigned plugin uses a different provider." = "已指派的某個外掛程式使用了其他供應商。"; +"Every assigned plugin is disabled." = "所有已指派的外掛程式均已停用。"; +"1 inspection plugin assigned." = "已指派 1 個檢查外掛程式。"; +"%lld inspection plugins assigned." = "已指派 %lld 個檢查外掛程式。"; +"Enter a provider profile name between 1 and 80 characters." = "請輸入 1 至 80 個字元的供應商設定檔名稱。"; +"Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed." = "請使用 HTTPS 端點;只有 localhost 可使用 HTTP。不允許包含使用者資訊、查詢參數和片段。"; +"Use a Keychain reference such as keychain:service/account, not a credential value." = "請使用 keychain:service/account 這類鑰匙串參照,而不是憑證值。"; +"Provider options must be short, non-secret key/value metadata." = "供應商選項必須是簡短的非機密鍵值中繼資料。"; +"An assigned plugin identifier is invalid." = "已指派的某個外掛程式識別碼無效。"; +"Provider profiles could not be loaded. Create them again in Inspect." = "無法載入供應商設定檔。請在「檢查」中重新建立。"; +"Healthy" = "狀態良好"; +"Needs setup" = "需要設定"; +"Limited visibility" = "可見性受限"; +"Attention" = "注意"; +"Not observed" = "未觀察到"; +"Detected agent" = "偵測到的代理"; +"Traffic was observed in the selected window." = "在所選時間範圍內觀察到流量。"; +"Configuration was found, but no traffic was observed in the selected window." = "已找到設定,但在所選時間範圍內未觀察到流量。"; +"Installed on this Mac, with no traffic in the selected window." = "已安裝在這台 Mac 上,但所選時間範圍內沒有流量。"; +"No traffic was observed in the selected window." = "在所選時間範圍內未觀察到流量。"; +"Always Monitor is configured, but no current route was observed." = "已設定「一律監控」,但未觀察到目前的路由。"; +"Its route through Flowlight was observed." = "觀察到其流量經過 Flowlight 的路由。"; +"The saved Flowlight route was not observed in this window." = "在此時間範圍內未觀察到已儲存的 Flowlight 路由。"; +"Flowlight could not establish the requested route." = "Flowlight 無法建立要求的路由。"; +"HTTPS inspection is unavailable in this build; only connection metadata is visible." = "此版本無法使用 HTTPS 檢查;只能看到連線中繼資料。"; +"HTTPS inspection is off; request contents are not visible." = "HTTPS 檢查已關閉;無法看到請求內容。"; +"The HTTPS inspection certificate is not trusted." = "HTTPS 檢查憑證不受信任。"; +"HTTPS inspection is enabled, but its proxy is not ready." = "HTTPS 檢查已啟用,但其代理伺服器尚未就緒。"; +"No inspected requests were observed in this window." = "此時間範圍內未觀察到受檢查的請求。"; +"HTTPS inspection observed this agent's traffic." = "HTTPS 檢查觀察到此代理的流量。"; +"One observed destination is outside its policy." = "有一個觀察到的目標不在其政策範圍內。"; +"%lld observed destinations are outside its policy." = "有 %lld 個觀察到的目標不在其政策範圍內。"; +"Its enforcing destination policy covers the observed traffic." = "其強制執行的目標政策涵蓋了觀察到的流量。"; +"Its reporting destination policy covers the observed traffic." = "其僅回報的目標政策涵蓋了觀察到的流量。"; +"No agent destination policy is enabled." = "未啟用代理目標政策。"; +"One unresolved alert needs review." = "有一則未解決的警示需要檢閱。"; +"%lld unresolved alerts need review." = "有 %lld 則未解決的警示需要檢閱。"; +"Installed, configured, and observed AI agents, with explainable setup and visibility health." = "已安裝、已設定和已觀察到的 AI 代理,並說明設定和可見性狀態。"; +"No AI agents found" = "找不到 AI 代理"; +"Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider." = "Flowlight 會盤點支援的 App 和已設定的命令列代理,並找出會連線到已知 AI 供應商的非瀏覽器用戶端。"; +"Agents inventoried" = "已盤點代理"; +"Active in window" = "時間範圍內活躍"; +"Health" = "狀態"; +"Why" = "原因"; +"Policy" = "政策"; +"Not set" = "未設定"; +"Enforcing" = "強制執行"; +"Reporting" = "僅回報"; +"No traffic detail is available for this agent in the selected window." = "所選時間範圍內沒有此代理的流量詳細資料。"; +"No matching alerts" = "沒有符合的警示"; +"Nothing matches this rule" = "沒有項目符合此規則"; +"State" = "狀態"; +"Disposition" = "處置"; +"Acknowledge all new (%lld)" = "確認所有新警示(%lld)"; +"Triage" = "分類處理"; +"Update the selected alerts" = "更新所選警示"; +"Set State" = "設定狀態"; +"Set Disposition" = "設定處置"; +"Edit Note…" = "編輯備註…"; +"Triage Note" = "分類備註"; +"%lld open, no new" = "%lld 則未結案,沒有新增"; +"%lld new of %lld open" = "%lld 則新增,共 %lld 則未結案"; +"%lld investigating" = "%lld 則調查中"; +"%lld resolved" = "%lld 則已解決"; +"%lld dismissed" = "%lld 則已忽略"; +"No open alerts match the current Focus scope." = "目前「焦點」範圍內沒有符合的未結案警示。"; +"No alerts are being investigated in the current Focus scope." = "目前「焦點」範圍內沒有正在調查的警示。"; +"No resolved alerts match the current Focus scope." = "目前「焦點」範圍內沒有符合的已解決警示。"; +"No dismissed alerts match the current Focus scope." = "目前「焦點」範圍內沒有符合的已忽略警示。"; +"New" = "新增"; +"Acknowledged" = "已確認"; +"Investigating" = "調查中"; +"Resolved" = "已解決"; +"Dismissed" = "已忽略"; +"Unset" = "未設定"; +"True positive" = "真正例"; +"Expected / benign" = "預期行為 / 無害"; +"False positive" = "誤判"; +"Open" = "未結案"; +"Plugins & Providers" = "外掛程式與供應商"; +"Manage inspection plugins and local provider profiles" = "管理檢查外掛程式和本機供應商設定檔"; +"Live network traffic" = "即時網路流量"; +"No time selected" = "未選取時間"; +"Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second." = "實心圓表示下載;虛線菱形表示上傳。使用向左鍵和向右鍵逐秒檢查。"; +"Download · solid" = "下載 · 實心"; +"Upload · dashed" = "上傳 · 虛線"; +"Inspection extensions, local routing metadata, and retained findings." = "檢查擴充功能、本機路由中繼資料和保留的發現結果。"; +"No provider profiles" = "沒有供應商設定檔"; +"Profiles group local provider settings with the inspection plugins that use them." = "設定檔會將本機供應商設定與使用這些設定的檢查外掛程式歸為一組。"; +"Add Provider…" = "加入供應商…"; +"Provider profiles" = "供應商設定檔"; +"Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile." = "設定檔只會保留在這台 Mac 上。其中包含非機密選項和可選的鑰匙串參照;Flowlight 絕不會在設定檔中儲存憑證值。"; +"Saved provider profile %@." = "已儲存供應商設定檔 %@。"; +"Remove provider profile?" = "移除供應商設定檔?"; +"Removed provider profile %@." = "已移除供應商設定檔 %@。"; +"Could not remove provider profile." = "無法移除供應商設定檔。"; +"Plugins and their retained findings stay installed." = "外掛程式及其保留的發現結果仍會保持安裝。"; +"Credential value kept in Keychain" = "憑證值儲存在鑰匙串中"; +"Add Provider Profile" = "加入供應商設定檔"; +"Edit Provider Profile" = "編輯供應商設定檔"; +"Identity" = "識別資訊"; +"Enabled" = "已啟用"; +"Endpoint (optional)" = "端點(可選)"; +"Keychain reference (optional)" = "鑰匙串參照(可選)"; +"Use keychain:service/account. The credential value is never stored in this profile." = "請使用 keychain:service/account。憑證值絕不會儲存在此設定檔中。"; +"No installed plugins use this provider." = "沒有已安裝的外掛程式使用此供應商。"; +"Plugin enabled" = "外掛程式已啟用"; +"Plugin disabled" = "外掛程式已停用"; +"Inspection plugins" = "檢查外掛程式"; +"Option" = "選項"; +"Remove provider option" = "移除供應商選項"; +"Add Option" = "加入選項"; +"Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected." = "選項是本機非機密中繼資料。API 金鑰、權杖、密碼及其他憑證會被拒絕。"; +"Provider options" = "供應商選項"; diff --git a/Flowlight/Plugins/PluginEngine.swift b/Flowlight/Plugins/PluginEngine.swift index 9b27fb8e..742c6b79 100644 --- a/Flowlight/Plugins/PluginEngine.swift +++ b/Flowlight/Plugins/PluginEngine.swift @@ -34,8 +34,17 @@ enum PluginEngine { static var builtInManifests: [PluginManifest] { builtInRules.map(\.manifest) } - static func evaluate(_ exchange: HTTPExchange, manifests: [PluginManifest]) -> [PluginFindingDraft] { - evaluateBuiltIns(exchange, manifests: manifests) + PluginScriptRunner.shared.evaluate(exchange: exchange, manifests: manifests) + static func evaluate( + _ exchange: HTTPExchange, + manifests: [PluginManifest], + providerProfiles: [ProviderRuntimeProfile] = [] + ) -> [PluginFindingDraft] { + evaluateBuiltIns(exchange, manifests: manifests) + + PluginScriptRunner.shared.evaluate( + exchange: exchange, + manifests: manifests, + providerProfiles: providerProfiles + ) } static func evaluateBuiltIns(_ exchange: HTTPExchange, manifests: [PluginManifest]) -> [PluginFindingDraft] { diff --git a/Flowlight/Plugins/PluginScriptRunner.swift b/Flowlight/Plugins/PluginScriptRunner.swift index 5b97aa16..4fcbf6b4 100644 --- a/Flowlight/Plugins/PluginScriptRunner.swift +++ b/Flowlight/Plugins/PluginScriptRunner.swift @@ -4,8 +4,11 @@ import Foundation final class PluginScriptRunner: @unchecked Sendable { static let shared = PluginScriptRunner() static let maxScriptBytes = 128 * 1024 - static let maxInputBytes = 512 * 1024 - static let maxOutputBytes = 512 * 1024 + // Match the helper's bounded transport envelope. Provider profiles are validated individually, but a legitimate + // plugin may be assigned several of them; the old 512 KB client-side cap could silently skip evaluation before + // the helper saw an otherwise valid request. + static let maxInputBytes = 4 * 1024 * 1024 + static let maxOutputBytes = 4 * 1024 * 1024 static let timeout: TimeInterval = 0.25 private final class OutputCapture: @unchecked Sendable { @@ -18,15 +21,25 @@ final class PluginScriptRunner: @unchecked Sendable { private init() {} - func evaluate(exchange: HTTPExchange, manifests: [PluginManifest]) -> [PluginFindingDraft] { + func evaluate( + exchange: HTTPExchange, + manifests: [PluginManifest], + providerProfiles: [ProviderRuntimeProfile] = [] + ) -> [PluginFindingDraft] { #if APP_STORE return [] #else - return manifests.flatMap { evaluate(exchange: exchange, manifest: $0) } + return manifests.flatMap { + evaluate(exchange: exchange, manifest: $0, providerProfiles: providerProfiles) + } #endif } - func evaluate(exchange: HTTPExchange, manifest: PluginManifest) -> [PluginFindingDraft] { + func evaluate( + exchange: HTTPExchange, + manifest: PluginManifest, + providerProfiles: [ProviderRuntimeProfile] = [] + ) -> [PluginFindingDraft] { #if APP_STORE return [] #else @@ -35,7 +48,7 @@ final class PluginScriptRunner: @unchecked Sendable { let input: [String: Any] = [ "mode": "plugin", "script": manifest.script, - "manifest": manifestPayload(manifest), + "manifest": manifestPayload(manifest, providerProfiles: providerProfiles), "context": contextPayload(exchange, permissions: Set(manifest.permissions)), ] guard JSONSerialization.isValidJSONObject(input), @@ -259,8 +272,20 @@ final class PluginScriptRunner: @unchecked Sendable { String(((value as? String) ?? fallback).trimmingCharacters(in: .whitespacesAndNewlines).prefix(count)) } - private func manifestPayload(_ manifest: PluginManifest) -> [String: Any] { - [ + private func manifestPayload( + _ manifest: PluginManifest, + providerProfiles: [ProviderRuntimeProfile] + ) -> [String: Any] { + let activeProfiles: [[String: Any]] = providerProfiles.filter { $0.applies(to: manifest) } + .sorted { $0.id.uuidString < $1.id.uuidString }.map { profile in + [ + "id": profile.id.uuidString.lowercased(), + "provider": profile.provider.rawValue, + "endpoint": profile.endpoint ?? NSNull(), + "configuration": profile.configuration, + ] + } + return [ "id": manifest.id, "name": manifest.name, "version": manifest.version, @@ -274,6 +299,7 @@ final class PluginScriptRunner: @unchecked Sendable { "summary": manifest.privacy.summary, ], "configuration": manifest.configuration, + "providerProfiles": activeProfiles, ] } diff --git a/Flowlight/Plugins/ProviderProfileStore.swift b/Flowlight/Plugins/ProviderProfileStore.swift new file mode 100644 index 00000000..339690c4 --- /dev/null +++ b/Flowlight/Plugins/ProviderProfileStore.swift @@ -0,0 +1,278 @@ +import Foundation + +/// Local routing metadata for an inspection provider. A profile may point at a +/// Keychain item, but it never owns or serializes that item's value. +struct ProviderRuntimeProfile: Equatable, Sendable { + var id: UUID + var provider: PluginManifest.GuardrailProvider + var endpoint: String? + var configuration: [String: String] + var pluginIDs: [String] + + func applies(to manifest: PluginManifest) -> Bool { + provider == manifest.guardrailProvider && pluginIDs.contains(manifest.id) + } +} + +struct ProviderProfile: Codable, Equatable, Identifiable, Sendable { + static let schemaVersion = 1 + + var id: UUID + var name: String + var provider: PluginManifest.GuardrailProvider + var enabled: Bool + var endpoint: String? + var configuration: [String: String] + var pluginIDs: [String] + var credentialReference: String? + + init( + id: UUID = UUID(), + name: String, + provider: PluginManifest.GuardrailProvider, + enabled: Bool = true, + endpoint: String? = nil, + configuration: [String: String] = [:], + pluginIDs: [String] = [], + credentialReference: String? = nil + ) { + self.id = id + self.name = name + self.provider = provider + self.enabled = enabled + self.endpoint = endpoint + self.configuration = configuration + self.pluginIDs = pluginIDs + self.credentialReference = credentialReference + } + + enum Health: Equatable { + case disabled + case attention(String) + case ready(String) + + var title: String { + switch self { + case .disabled: return L("Off") + case .attention: return L("Needs attention") + case .ready: return L("Ready") + } + } + + var reason: String { + switch self { + case .disabled: + return L("This provider profile is disabled.") + case .attention(let reason), .ready(let reason): + return reason + } + } + } + + func health(manifests: [PluginManifest]) -> Health { + guard enabled else { return .disabled } + guard !pluginIDs.isEmpty else { + return .attention(L("Assign at least one inspection plugin.")) + } + let byID = Dictionary(uniqueKeysWithValues: manifests.map { ($0.id, $0) }) + if pluginIDs.contains(where: { byID[$0] == nil }) { + return .attention(L("An assigned plugin is no longer installed.")) + } + if pluginIDs.contains(where: { byID[$0]?.guardrailProvider != provider }) { + return .attention(L("An assigned plugin uses a different provider.")) + } + if pluginIDs.allSatisfy({ byID[$0]?.enabled == false }) { + return .attention(L("Every assigned plugin is disabled.")) + } + let count = pluginIDs.count + return .ready(count == 1 ? L("1 inspection plugin assigned.") : L("%lld inspection plugins assigned.", count)) + } +} + +enum ProviderProfileValidationError: LocalizedError, Equatable { + case name + case endpoint + case credentialReference + case configuration + case pluginID + + var errorDescription: String? { + switch self { + case .name: return L("Enter a provider profile name between 1 and 80 characters.") + case .endpoint: return L("Use an HTTPS endpoint, or HTTP only for localhost. User information, query parameters, and fragments are not allowed.") + case .credentialReference: return L("Use a Keychain reference such as keychain:service/account, not a credential value.") + case .configuration: return L("Provider options must be short, non-secret key/value metadata.") + case .pluginID: return L("An assigned plugin identifier is invalid.") + } + } +} + +enum ProviderProfileValidator { + private static let secretKeyTerms = ["apikey", "authorization", "bearer", "credential", "password", "privatekey", "secret", "token"] + private static let credentialValuePrefixes = [ + "akia", "asia", "aiza", "bearer ", "basic ", "ghp_", "github_pat_", "keychain:", "pplx-", "sk-", "sk_", "xox", + ] + + private static func resemblesCredential(_ value: String) -> Bool { + let lowercased = value.lowercased() + if credentialValuePrefixes.contains(where: lowercased.hasPrefix) { return true } + if value.contains("-----BEGIN ") && value.contains("PRIVATE KEY-----") { return true } + if value.range(of: #"^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil { + return true + } + // Provider options such as regions, model names, and policy labels are human-readable. A long compact token + // belongs in the Keychain even when its option key has an innocuous name such as `account` or `value`. + return value.count >= 32 + && value.range(of: #"^[A-Za-z0-9_+/=-]+$"#, options: .regularExpression) != nil + } + + static func normalized(_ profile: ProviderProfile) throws -> ProviderProfile { + var result = profile + result.name = result.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard (1...80).contains(result.name.count), !result.name.contains(where: { $0.isNewline }) else { + throw ProviderProfileValidationError.name + } + + result.endpoint = result.endpoint?.trimmingCharacters(in: .whitespacesAndNewlines).providerNilIfEmpty + if let endpoint = result.endpoint { + guard endpoint.count <= 500, + let components = URLComponents(string: endpoint), + let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased(), !host.isEmpty, + components.user == nil, components.password == nil, + components.query == nil, components.fragment == nil, + scheme == "https" || (scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host)) else { + throw ProviderProfileValidationError.endpoint + } + } + + result.credentialReference = result.credentialReference?.trimmingCharacters(in: .whitespacesAndNewlines).providerNilIfEmpty + if let reference = result.credentialReference { + guard reference.count <= 160, + reference.range(of: #"^keychain:[A-Za-z0-9._:/-]+$"#, options: .regularExpression) != nil else { + throw ProviderProfileValidationError.credentialReference + } + } + + guard result.configuration.count <= 32 else { throw ProviderProfileValidationError.configuration } + var normalizedConfiguration: [String: String] = [:] + for (rawKey, rawValue) in result.configuration { + let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines) + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + let comparable = key.lowercased().filter(\.isLetter) + guard (1...80).contains(key.count), value.count <= 500, + !key.contains(where: { $0.isNewline }), !value.contains(where: { $0.isNewline }), + !secretKeyTerms.contains(where: comparable.contains), !resemblesCredential(value) else { + throw ProviderProfileValidationError.configuration + } + normalizedConfiguration[key] = value + } + result.configuration = normalizedConfiguration + + result.pluginIDs = Array(Set(result.pluginIDs.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) })).sorted() + guard result.pluginIDs.count <= 128, + result.pluginIDs.allSatisfy({ !$0.isEmpty && $0.count <= 160 && !$0.contains(where: { $0.isWhitespace }) }) else { + throw ProviderProfileValidationError.pluginID + } + return result + } +} + +@MainActor +final class ProviderProfileStore: ObservableObject { + @Published private(set) var profiles: [ProviderProfile] = [] + @Published private(set) var loadIssue: String? + + private let defaults: UserDefaults + private let key: String + private var runtimeSink: (([ProviderRuntimeProfile]) -> Void)? + + init(defaults: UserDefaults = .standard, key: String = "inspectionProviderProfiles") { + self.defaults = defaults + self.key = key + reload() + } + + /// Publishes only validated, enabled, credential-free settings. The sink is explicit rather than a Combine + /// subscription because `@Published` announces a mutation before the new value is installed. + func installRuntimeSink(_ sink: @escaping ([ProviderRuntimeProfile]) -> Void) { + runtimeSink = sink + sink(runtimeSnapshot) + } + + func reload() { + guard let data = defaults.data(forKey: key) else { + profiles = [] + loadIssue = nil + publishRuntimeSnapshot() + return + } + do { + let document = try JSONDecoder().decode(Document.self, from: data) + guard document.schemaVersion == ProviderProfile.schemaVersion else { throw DocumentError.schema } + profiles = try document.profiles.map(ProviderProfileValidator.normalized).sorted(by: Self.order) + loadIssue = nil + } catch { + profiles = [] + loadIssue = L("Provider profiles could not be loaded. Create them again in Inspect.") + } + publishRuntimeSnapshot() + } + + func save(_ profile: ProviderProfile) throws { + let normalized = try ProviderProfileValidator.normalized(profile) + var next = profiles + if let index = next.firstIndex(where: { $0.id == normalized.id }) { + next[index] = normalized + } else { + next.append(normalized) + } + next.sort(by: Self.order) + try persist(next) + profiles = next + loadIssue = nil + publishRuntimeSnapshot() + } + + func remove(id: ProviderProfile.ID) throws { + var next = profiles + next.removeAll { $0.id == id } + try persist(next) + profiles = next + loadIssue = nil + publishRuntimeSnapshot() + } + + private var runtimeSnapshot: [ProviderRuntimeProfile] { + profiles.filter(\.enabled).map { + ProviderRuntimeProfile(id: $0.id, provider: $0.provider, endpoint: $0.endpoint, + configuration: $0.configuration, pluginIDs: $0.pluginIDs) + }.sorted { $0.id.uuidString < $1.id.uuidString } + } + + private func publishRuntimeSnapshot() { + runtimeSink?(runtimeSnapshot) + } + + private func persist(_ profiles: [ProviderProfile]) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + defaults.set(try encoder.encode(Document(schemaVersion: ProviderProfile.schemaVersion, profiles: profiles)), forKey: key) + } + + private static func order(_ lhs: ProviderProfile, _ rhs: ProviderProfile) -> Bool { + let comparison = lhs.name.localizedCaseInsensitiveCompare(rhs.name) + return comparison == .orderedSame ? lhs.id.uuidString < rhs.id.uuidString : comparison == .orderedAscending + } + + private struct Document: Codable { + var schemaVersion: Int + var profiles: [ProviderProfile] + } + + private enum DocumentError: Error { case schema } +} + +private extension String { + var providerNilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Flowlight/Storage/TrafficDatabase.swift b/Flowlight/Storage/TrafficDatabase.swift index da133f1c..226bd864 100644 --- a/Flowlight/Storage/TrafficDatabase.swift +++ b/Flowlight/Storage/TrafficDatabase.swift @@ -197,7 +197,70 @@ struct BucketContributors: Sendable, Equatable { var remaining: FlowCounters } +enum AlertTriageState: String, CaseIterable, Sendable, Hashable { + case new + case acknowledged + case investigating + case resolved + case dismissed + + var isClosed: Bool { self == .resolved || self == .dismissed } + + func canTransition(to next: AlertTriageState) -> Bool { + if self == next { return true } + switch self { + case .new: + return true + case .acknowledged: + return next == .investigating || next == .resolved || next == .dismissed + case .investigating: + return next == .resolved || next == .dismissed + case .resolved, .dismissed: + return next == .investigating + } + } +} + +enum AlertDisposition: String, CaseIterable, Sendable, Hashable { + case unset + case truePositive = "true_positive" + case expectedBenign = "expected_benign" + case falsePositive = "false_positive" +} + +enum AlertTriageFilter: String, CaseIterable, Sendable, Hashable { + case open + case investigating + case resolved + case dismissed + + fileprivate var states: [AlertTriageState] { + switch self { + case .open: return [.new, .acknowledged, .investigating] + case .investigating: return [.investigating] + case .resolved: return [.resolved] + case .dismissed: return [.dismissed] + } + } +} + +enum AlertTriageError: Error, Equatable { + case alertNotFound(Int64) + case invalidTransition(from: AlertTriageState, to: AlertTriageState) + case noteTooLong(maximum: Int) +} + +/// Database-side rollup used by the agent inventory. Each row represents distinct alerts sharing the same +/// recorded app identity, so callers never need to materialize an unbounded alert history just to show counts. +struct AgentOpenAlertCount: Sendable, Equatable { + var bundleID: String + var appName: String + var count: Int +} + struct AlertRecord: Identifiable, Sendable, Hashable { + static let maximumNoteLength = 2_000 + var id: Int64 var timestamp: Date var kind: String @@ -205,10 +268,39 @@ struct AlertRecord: Identifiable, Sendable, Hashable { var appName: String var detail: String var severity: Int - var acknowledged: Bool + var state: AlertTriageState + var disposition: AlertDisposition + var note: String + var triageUpdatedAt: Date /// What "Allow from now on" would add to `bundleID`'s allowlist, for alerts that come from one. Empty for /// every other kind, which is what hides the offer. var allowPattern: String = "" + + /// Compatibility for exporters and callers written before triage: every state except `new` has been seen. + var acknowledged: Bool { state != .new } + + init(id: Int64, timestamp: Date, kind: String, bundleID: String, appName: String, detail: String, severity: Int, + state: AlertTriageState = .new, disposition: AlertDisposition = .unset, note: String = "", + triageUpdatedAt: Date? = nil, allowPattern: String = "") { + self.id = id + self.timestamp = timestamp + self.kind = kind + self.bundleID = bundleID + self.appName = appName + self.detail = detail + self.severity = severity + self.state = state + self.disposition = disposition + self.note = note + self.triageUpdatedAt = triageUpdatedAt ?? timestamp + self.allowPattern = allowPattern + } + + init(id: Int64, timestamp: Date, kind: String, bundleID: String, appName: String, detail: String, severity: Int, + acknowledged: Bool, allowPattern: String = "") { + self.init(id: id, timestamp: timestamp, kind: kind, bundleID: bundleID, appName: appName, detail: detail, + severity: severity, state: acknowledged ? .acknowledged : .new, allowPattern: allowPattern) + } } /// One decision a rule made, as the violations feed shows it. @@ -279,6 +371,8 @@ final class TrafficDatabase: @unchecked Sendable { let queue = DispatchQueue(label: "flowlight.db", qos: .userInitiated) private let conn: SQLiteConnection let url: URL + /// App-lifetime, credential-free provider settings. The writer queue replaces this whole value atomically. + private var runtimeProviderProfiles: [ProviderRuntimeProfile] = [] /// Always Application Support, so history survives a build gaining (or losing) the App Group entitlement and /// a change of Team ID. The extension never opens the database; it only shares the group for its XPC service. @@ -641,6 +735,32 @@ final class TrafficDatabase: @unchecked Sendable { if !alertColumns.contains("allow_pattern") { try conn.execute("ALTER TABLE alerts ADD COLUMN allow_pattern TEXT NOT NULL DEFAULT ''") } + // 0.17.5 adds local single-user triage without rewriting or replacing the alert table. The old bit remains + // synchronized for database compatibility, and seeds the richer lifecycle exactly once on migration. Keep the + // column additions, backfills, and index atomic: a process interruption must not leave defaults that look like + // a completed migration on the next launch. + let needsTriageState = !alertColumns.contains("triage_state") + let needsTriageTimestamp = !alertColumns.contains("triage_updated_at") + try conn.transaction { + if needsTriageState { + try conn.execute("ALTER TABLE alerts ADD COLUMN triage_state TEXT NOT NULL DEFAULT 'new'") + } + // Also repairs a prerelease database interrupted after adding triage_state but before its later columns. + if needsTriageState || needsTriageTimestamp { + try conn.execute("UPDATE alerts SET triage_state = CASE WHEN acknowledged = 0 THEN 'new' ELSE 'acknowledged' END") + } + if !alertColumns.contains("disposition") { + try conn.execute("ALTER TABLE alerts ADD COLUMN disposition TEXT NOT NULL DEFAULT 'unset'") + } + if !alertColumns.contains("triage_note") { + try conn.execute("ALTER TABLE alerts ADD COLUMN triage_note TEXT NOT NULL DEFAULT ''") + } + if needsTriageTimestamp { + try conn.execute("ALTER TABLE alerts ADD COLUMN triage_updated_at REAL NOT NULL DEFAULT 0") + try conn.execute("UPDATE alerts SET triage_updated_at = ts WHERE triage_updated_at = 0") + } + try conn.execute("CREATE INDEX IF NOT EXISTS alerts_triage_state_ts ON alerts(triage_state, ts)") + } // Added in 0.1.5. let exchangeColumns = Set(try conn.query("PRAGMA table_info(http_exchanges)") { $0.text(1) }) for column in ["tool_results", "mcp", "llm"] where !exchangeColumns.contains(column) { @@ -655,6 +775,16 @@ final class TrafficDatabase: @unchecked Sendable { if !exchangeColumns.contains("guardrail") { try conn.execute("ALTER TABLE http_exchanges ADD COLUMN guardrail TEXT NOT NULL DEFAULT ''") } + // Request rewrites used the guardrail column before central policy activity needed to distinguish their + // effects. The durable marker, rather than column presence, completes the migration: a prerelease launch may + // have added the column and stopped before finishing the schema transaction. Every legacy value is inherently + // ambiguous once its short-lived RuleEvent has expired — even the old join delimiter may appear inside a + // user-entered JSON path — so history stays untouched rather than fabricating an effect. + let needsRequestRewriteColumn = !exchangeColumns.contains("request_rewrite") + let inspectionEffectSplitVersion = try conn.query( + "SELECT watermark FROM rollup_state WHERE tier = 'schema.inspectionEffectSplit'", + map: { $0.int(0) } + ).first ?? 0 // Unlike a mock, this names a response the origin really sent before Flowlight changed it for the client. if !exchangeColumns.contains("response_transform") { try conn.execute("ALTER TABLE http_exchanges ADD COLUMN response_transform TEXT NOT NULL DEFAULT ''") @@ -727,6 +857,28 @@ final class TrafficDatabase: @unchecked Sendable { CREATE INDEX IF NOT EXISTS rule_pack_events_time ON rule_pack_event_rollups(bucket DESC); CREATE INDEX IF NOT EXISTS rule_pack_events_pack ON rule_pack_event_rollups(pack_id, bucket DESC); """) + if needsRequestRewriteColumn || inspectionEffectSplitVersion < 1 { + // The schema change, conservative data split, and marker are one transaction. On interruption SQLite + // rolls all three back; if an earlier prerelease added only the column, the missing marker still runs this. + try conn.transaction { + if needsRequestRewriteColumn { + try conn.execute("ALTER TABLE http_exchanges ADD COLUMN request_rewrite TEXT NOT NULL DEFAULT ''") + } + // A current guardrail row proves the event's policy kind without correlating unrelated requests by + // timestamp. Deleted historical policies cannot be identified safely and retain their original event + // engine; their exchange metadata below still preserves the guardrail effect. + try conn.execute(""" + UPDATE rule_events SET engine = 'guardrail' + WHERE engine = 'request' AND rule_id IN (SELECT id FROM guardrails) + """) + // Legacy exchange text is not rewritten. A middle dot is valid user data as well as the old join + // delimiter, and there is no durable per-effect identity that can distinguish those cases safely. + try conn.execute(""" + INSERT OR REPLACE INTO rollup_state (tier, watermark) + VALUES ('schema.inspectionEffectSplit', 1) + """) + } + } let packEventKeyVersion = try conn.query( "SELECT watermark FROM rollup_state WHERE tier = 'schema.rulePackEventKey'", map: { $0.int(0) } @@ -1425,17 +1577,36 @@ final class TrafficDatabase: @unchecked Sendable { // MARK: Agent allowlists func loadPolicies() throws -> [String: AgentPolicy] { - let rows = try conn.query("SELECT policy FROM agent_policies") { $0.text(0) } - let policies = rows.compactMap { try? JSONDecoder().decode(AgentPolicy.self, from: Data($0.utf8)) } - return Dictionary(policies.map { ($0.agentID, $0) }, uniquingKeysWith: { a, _ in a }) + let rows = try conn.query(""" + SELECT agent_id, policy, updated FROM agent_policies + ORDER BY updated DESC, agent_id COLLATE NOCASE ASC, agent_id ASC + """) { ($0.text(0), $0.text(1), $0.int(2)) } + var selected: [AgentPolicy] = [] + for row in rows { + guard var policy = try? JSONDecoder().decode(AgentPolicy.self, from: Data(row.1.utf8)) else { continue } + policy.agentID = row.0 + guard AgentPolicy.resolve(selected, identities: [policy.agentID]) == nil else { continue } + selected.append(policy) + } + return Dictionary(uniqueKeysWithValues: selected.map { ($0.agentID, $0) }) } func savePolicy(_ policy: AgentPolicy) throws { let json = String(decoding: try JSONEncoder().encode(policy), as: UTF8.self) - try conn.run(""" - INSERT INTO agent_policies (agent_id, policy, updated) VALUES (?,?,?) - ON CONFLICT(agent_id) DO UPDATE SET policy = excluded.policy, updated = excluded.updated - """, [.text(policy.agentID), .text(json), .int(Int64(Date().timeIntervalSince1970))]) + try conn.transaction { + // One catalog agent has one policy. Remove obsolete alias-keyed copies so every reporter and enforcer + // receives the same decision even when older builds saved the agent under another stable identifier. + let aliases = try conn.query("SELECT agent_id FROM agent_policies") { $0.text(0) }.filter { + $0 != policy.agentID && AgentCatalog.sameAgentIdentity($0, policy.agentID) + } + for alias in aliases { + try conn.run("DELETE FROM agent_policies WHERE agent_id = ?", [.text(alias)]) + } + try conn.run(""" + INSERT INTO agent_policies (agent_id, policy, updated) VALUES (?,?,?) + ON CONFLICT(agent_id) DO UPDATE SET policy = excluded.policy, updated = excluded.updated + """, [.text(policy.agentID), .text(json), .int(Int64(Date().timeIntervalSince1970))]) + } } // MARK: Rules @@ -1666,7 +1837,8 @@ final class TrafficDatabase: @unchecked Sendable { var attempts = try conn.query(""" SELECT bundle_id, app_name, agent, agent_name, CASE WHEN host <> '' THEN host ELSE ip END, port, COUNT(*), rule_id, - CASE WHEN engine = 'flow' THEN 'Flow rule' ELSE 'Request rule' END + CASE engine WHEN 'flow' THEN 'Flow rule' WHEN 'guardrail' THEN 'Guardrail' ELSE 'Request rule' END, + engine FROM rule_events WHERE action = 'block' AND ts >= ? AND ts < ? GROUP BY bundle_id, app_name, agent, agent_name, host, ip, port, rule_id, engine @@ -1674,7 +1846,9 @@ final class TrafficDatabase: @unchecked Sendable { BlockedAttemptRow( bundleID: row.text(0), appName: row.text(1), agentKey: row.text(2), agentName: row.text(3), destination: row.text(4), port: UInt16(clamping: row.int(5)), count: row.int(6), - category: "rule", source: "ordinary-rule", detail: "\(row.text(8)) · \(row.text(7))" + category: row.text(9) == "guardrail" ? "guardrail" : "rule", + source: row.text(9) == "guardrail" ? "guardrail" : "ordinary-rule", + detail: "\(row.text(8)) · \(row.text(7))" ) } attempts += try conn.query(""" @@ -2273,6 +2447,11 @@ final class TrafficDatabase: @unchecked Sendable { // MARK: Plugins + /// Replaces one immutable runtime generation. Callers enqueue this on `queue`, alongside inserts and backfills. + func replaceProviderProfiles(_ profiles: [ProviderRuntimeProfile]) { + runtimeProviderProfiles = profiles + } + func loadPluginManifests() throws -> [PluginManifest] { let rows = try conn.query("SELECT manifest, enabled FROM plugins ORDER BY updated") { ($0.text(0), $0.int(1) != 0) } return rows.compactMap { text, enabled in @@ -2321,6 +2500,7 @@ final class TrafficDatabase: @unchecked Sendable { let evaluable = active.filter { $0.source == .builtIn || (!$0.script.isEmpty && $0.source == .installed) } guard !evaluable.isEmpty else { return 0 } let exchanges = try exchanges(since: since, limit: limit) + let profileSnapshot = runtimeProviderProfiles var inserted = 0 try conn.transaction { if let pluginID { try conn.run("DELETE FROM plugin_findings WHERE plugin_id = ?", [.text(pluginID)]) } @@ -2329,7 +2509,10 @@ final class TrafficDatabase: @unchecked Sendable { } for exchange in exchanges { let before = conn.changes - try insertPluginFindings(PluginEngine.evaluate(exchange, manifests: evaluable), forExchangeID: exchange.id ?? 0) + try insertPluginFindings( + PluginEngine.evaluate(exchange, manifests: evaluable, providerProfiles: profileSnapshot), + forExchangeID: exchange.id ?? 0 + ) inserted += max(0, conn.changes - before) } } @@ -2440,8 +2623,9 @@ final class TrafficDatabase: @unchecked Sendable { try conn.run(""" INSERT INTO http_exchanges (ts, duration, scheme, host, port, method, path, status, req_headers, req_body, req_size, req_truncated, resp_headers, resp_body, resp_size, resp_truncated, content_type, pid, bundle_id, app_name, agent, - agent_name, mcp_server, tool_calls, note, tool_results, mcp, llm, mock_rule, guardrail, response_transform) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + agent_name, mcp_server, tool_calls, note, tool_results, mcp, llm, mock_rule, guardrail, request_rewrite, + response_transform) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, [.double(e.started.timeIntervalSince1970), .double(e.duration), .text(e.scheme), .text(e.host), .int(Int64(e.port)), .text(e.method), .text(e.path), e.status.map { .int(Int64($0)) } ?? .null, .text(json(e.requestHeaders)), .blob(e.requestBody), .int(Int64(e.requestSize)), .int(e.requestTruncated ? 1 : 0), @@ -2449,7 +2633,8 @@ final class TrafficDatabase: @unchecked Sendable { .text(e.contentType), .int(Int64(e.pid)), .text(e.bundleID), .text(e.appName), .text(e.agent ?? ""), .text(e.agentName ?? ""), .text(e.mcpServer ?? ""), .text(e.toolCalls.isEmpty ? "" : json(e.toolCalls)), .text(e.note ?? ""), .text(e.toolResults.isEmpty ? "" : json(e.toolResults)), .text(e.mcp.isEmpty ? "" : json(e.mcp)), - .text(e.llm.map(json) ?? ""), .text(e.mockRule ?? ""), .text(e.guardrail ?? ""), .text(e.responseTransform ?? "")]) + .text(e.llm.map(json) ?? ""), .text(e.mockRule ?? ""), .text(e.guardrail ?? ""), + .text(e.requestRewrite ?? ""), .text(e.responseTransform ?? "")]) insertedID = try conn.query("SELECT last_insert_rowid()", map: { $0.int(0) }).first ?? 0 var stored = e stored.id = insertedID @@ -2476,7 +2661,14 @@ final class TrafficDatabase: @unchecked Sendable { guard !manifests.isEmpty else { return } var stored = exchange stored.id = exchangeID - try insertPluginFindings(PluginScriptRunner.shared.evaluate(exchange: stored, manifests: manifests), forExchangeID: exchangeID) + try insertPluginFindings( + PluginScriptRunner.shared.evaluate( + exchange: stored, + manifests: manifests, + providerProfiles: runtimeProviderProfiles + ), + forExchangeID: exchangeID + ) } catch { appLog.error("installed plugin evaluation skipped: \(String(describing: error), privacy: .public)") } @@ -2686,11 +2878,12 @@ final class TrafficDatabase: @unchecked Sendable { /// is a different question from "find this text", and answering the first with the second matches any /// response body that merely mentions the app's name — which is how asking for Claude Code's traffic /// returned every GitHub page whose HTML says "Claude Code". - func exchanges(since: Date, search: String = "", limit: Int = 500, focus: FocusScope = .none, - app: String? = nil, apps: [String] = [], host: String? = nil, + func exchanges(since: Date, search: String = "", limit: Int? = 500, + before: Date? = nil, beforeID: Int64? = nil, + focus: FocusScope = .none, app: String? = nil, apps: [String] = [], host: String? = nil, hostIncludesSubdomains: Bool = true, includeAttributedChildren: Bool = false, agentParent: String? = nil, mcpServer: String? = nil, - potentialHarmOnly: Bool = false) throws -> [HTTPExchange] { + potentialHarmOnly: Bool = false, policyEffectsOnly: Bool = false) throws -> [HTTPExchange] { let like = "%\(search)%" var scopeClause = "" var scopeValues: [SQLValue] = [] @@ -2750,16 +2943,31 @@ final class TrafficDatabase: @unchecked Sendable { AND r.severity IN ('medium', 'high') ) """ : "" + let policyClause = policyEffectsOnly + ? " AND (mock_rule <> '' OR guardrail <> '' OR request_rewrite <> '' OR response_transform <> '')" + : "" + let cursorClause: String + let cursorValues: [SQLValue] + if let before, let beforeID { + cursorClause = " AND (ts < ? OR (ts = ? AND id < ?))" + cursorValues = [.double(before.timeIntervalSince1970), .double(before.timeIntervalSince1970), .int(beforeID)] + } else { + cursorClause = "" + cursorValues = [] + } + let limitClause = limit == nil ? "" : " LIMIT ?" + var bindings: [SQLValue] = [.double(since.timeIntervalSince1970), .text(search), .text(like), .text(like), + .text(like), .text(like), .text(like), .text(like), .text(like), .text(like)] + + focusValues + scopeValues + cursorValues + if let limit { bindings.append(.int(Int64(limit))) } return try conn.query(""" SELECT id, ts, duration, scheme, host, port, method, path, status, req_headers, req_size, req_truncated, resp_headers, resp_size, resp_truncated, content_type, pid, bundle_id, app_name, agent, agent_name, mcp_server, tool_calls, note, - tool_results, mcp, llm, mock_rule, guardrail, response_transform + tool_results, mcp, llm, mock_rule, guardrail, request_rewrite, response_transform FROM http_exchanges WHERE ts >= ? AND (? = '' OR host LIKE ? OR path LIKE ? OR app_name LIKE ? OR agent_name LIKE ? OR tool_calls LIKE ? - OR mcp LIKE ? OR CAST(req_body AS TEXT) LIKE ? OR CAST(resp_body AS TEXT) LIKE ?)\(focusClause)\(scopeClause)\(riskClause) - ORDER BY ts DESC LIMIT ? - """, [.double(since.timeIntervalSince1970), .text(search), .text(like), .text(like), .text(like), .text(like), .text(like), .text(like), - .text(like), .text(like)] - + focusValues + scopeValues + [.int(Int64(limit))]) { row in + OR mcp LIKE ? OR CAST(req_body AS TEXT) LIKE ? OR CAST(resp_body AS TEXT) LIKE ?)\(focusClause)\(scopeClause)\(riskClause)\(policyClause)\(cursorClause) + ORDER BY ts DESC, id DESC\(limitClause) + """, bindings) { row in let decoder = JSONDecoder() func headers(_ i: Int32) -> [HTTPHeader] { (try? decoder.decode([HTTPHeader].self, from: Data(row.text(i).utf8))) ?? [] } let tools = row.text(22), results = row.text(24), mcp = row.text(25), llm = row.text(26) @@ -2775,8 +2983,34 @@ final class TrafficDatabase: @unchecked Sendable { mcp: mcp.isEmpty ? [] : ((try? decoder.decode([MCPActivity].self, from: Data(mcp.utf8))) ?? []), llm: llm.isEmpty ? nil : try? decoder.decode(LLMFacts.self, from: Data(llm.utf8)), note: row.text(23).nilIfEmpty, mockRule: row.text(27).nilIfEmpty, guardrail: row.text(28).nilIfEmpty, - responseTransform: row.text(29).nilIfEmpty) + requestRewrite: row.text(29).nilIfEmpty, responseTransform: row.text(30).nilIfEmpty) + } + } + + /// Every agent with at least one decrypted exchange in the complete selected window. This intentionally has no + /// row limit: the inventory uses it as health evidence, while `exchanges` remains bounded for detail rendering. + func inspectedAgentIDs(since: Date, focus: FocusScope = .none) throws -> Set { + var focusClause = "" + var values: [SQLValue] = [.double(since.timeIntervalSince1970)] + if !focus.isEmpty { + var parts: [String] = [] + if !focus.bundleIDs.isEmpty { + let marks = Array(repeating: "?", count: focus.bundleIDs.count).joined(separator: ",") + parts.append("(bundle_id IN (\(marks)) OR agent IN (\(marks)))") + values += focus.bundleIDs.map { .text($0) } + values += focus.bundleIDs.map { .text($0) } + } + for host in focus.hosts where !AgentPolicy.isIPAddress(host) { + parts.append("(host = ? OR host LIKE ?)") + values.append(.text(host)) + values.append(.text("%." + host)) + } + focusClause = parts.isEmpty ? " AND 0" : " AND (" + parts.joined(separator: " OR ") + ")" } + return Set(try conn.query(""" + SELECT DISTINCT agent FROM http_exchanges + WHERE ts >= ? AND agent != ''\(focusClause) + """, values) { $0.text(0) }) } func exchangeBodies(id: Int64) throws -> (request: Data, response: Data)? { @@ -2856,49 +3090,186 @@ final class TrafficDatabase: @unchecked Sendable { func addAlert(kind: String, bundleID: String, appName: String, detail: String, severity: Int, at date: Date = Date(), allowPattern: String = "") throws -> AlertRecord { let ts = Int64(date.timeIntervalSince1970) - try conn.run("INSERT INTO alerts (ts, kind, bundle_id, app_name, detail, severity, allow_pattern) VALUES (?,?,?,?,?,?,?)", - [.int(ts), .text(kind), .text(bundleID), .text(appName), .text(detail), .int(Int64(severity)), - .text(allowPattern)]) + try conn.run(""" + INSERT INTO alerts + (ts, kind, bundle_id, app_name, detail, severity, acknowledged, allow_pattern, + triage_state, disposition, triage_note, triage_updated_at) + VALUES (?,?,?,?,?,?,0,?,'new','unset','',?) + """, [.int(ts), .text(kind), .text(bundleID), .text(appName), .text(detail), .int(Int64(severity)), + .text(allowPattern), .double(date.timeIntervalSince1970)]) let id = try conn.query("SELECT last_insert_rowid()") { $0.int(0) }.first ?? 0 return AlertRecord(id: id, timestamp: date, kind: kind, bundleID: bundleID, appName: appName, detail: detail, - severity: severity, acknowledged: false, allowPattern: allowPattern) + severity: severity, triageUpdatedAt: date, allowPattern: allowPattern) } /// Alerts newest first. An alert names the app that raised it and nothing about where it went, so Focus can /// only narrow these by app: focusing purely on destinations leaves the list alone rather than emptying it. - func alerts(limit: Int = 500, focus: FocusScope = .none) throws -> [AlertRecord] { - var clause = "" + func alerts(limit: Int = 500, focus: FocusScope = .none, + triageFilter: AlertTriageFilter? = nil, kind: String? = nil) throws -> [AlertRecord] { + var conditions: [String] = [] var values: [SQLValue] = [] if !focus.bundleIDs.isEmpty { - clause = " WHERE bundle_id IN (\(Array(repeating: "?", count: focus.bundleIDs.count).joined(separator: ",")))" - values = focus.bundleIDs.map { .text($0) } + conditions.append("bundle_id IN (\(Array(repeating: "?", count: focus.bundleIDs.count).joined(separator: ",")))") + values += focus.bundleIDs.map { .text($0) } + } + if let triageFilter { + conditions.append("triage_state IN (\(Array(repeating: "?", count: triageFilter.states.count).joined(separator: ",")))") + values += triageFilter.states.map { .text($0.rawValue) } + } + if let kind { + conditions.append("kind = ?") + values.append(.text(kind)) } + let clause = conditions.isEmpty ? "" : " WHERE " + conditions.joined(separator: " AND ") return try conn.query(""" - SELECT id, ts, kind, bundle_id, app_name, detail, severity, acknowledged, allow_pattern FROM alerts\(clause) \ - ORDER BY ts DESC, id DESC LIMIT ? + SELECT id, ts, kind, bundle_id, app_name, detail, severity, allow_pattern, + triage_state, disposition, triage_note, triage_updated_at + FROM alerts\(clause) ORDER BY ts DESC, id DESC LIMIT ? """, values + [.int(Int64(limit))]) { r in - AlertRecord(id: r.int(0), timestamp: Date(timeIntervalSince1970: TimeInterval(r.int(1))), kind: r.text(2), - bundleID: r.text(3), appName: r.text(4), detail: r.text(5), severity: Int(r.int(6)), - acknowledged: r.int(7) != 0, allowPattern: r.text(8)) + let timestamp = Date(timeIntervalSince1970: TimeInterval(r.int(1))) + return AlertRecord( + id: r.int(0), timestamp: timestamp, kind: r.text(2), bundleID: r.text(3), appName: r.text(4), + detail: r.text(5), severity: Int(r.int(6)), + state: AlertTriageState(rawValue: r.text(8)) ?? .new, + disposition: AlertDisposition(rawValue: r.text(9)) ?? .unset, + note: r.text(10), + triageUpdatedAt: r.isNull(11) ? timestamp : Date(timeIntervalSince1970: r.double(11)), + allowPattern: r.text(7) + ) } } - /// Narrowed by Focus the same way `alerts` is, so the sidebar badge matches the list it opens. - func unacknowledgedAlertCount(focus: FocusScope = .none) throws -> Int { + /// Open-alert counts grouped by their recorded app identity. The AI-agent inventory can join these compact rows + /// against catalog aliases without loading every alert in the selected window. + func openAlertCounts(since: Date, focus: FocusScope = .none) throws -> [AgentOpenAlertCount] { + var conditions = ["ts >= ?", "triage_state IN ('new','acknowledged','investigating')"] + var values: [SQLValue] = [.int(Int64(since.timeIntervalSince1970))] + if !focus.bundleIDs.isEmpty { + conditions.append("bundle_id IN (\(Array(repeating: "?", count: focus.bundleIDs.count).joined(separator: ",")))") + values += focus.bundleIDs.map { .text($0) } + } + return try conn.query(""" + SELECT bundle_id, app_name, COUNT(*) FROM alerts + WHERE \(conditions.joined(separator: " AND ")) + GROUP BY bundle_id, app_name + ORDER BY bundle_id COLLATE NOCASE, app_name COLLATE NOCASE + """, values) { + AgentOpenAlertCount(bundleID: $0.text(0), appName: $0.text(1), count: Int($0.int(2))) + } + } + + /// The badge is deliberately narrower than "not resolved": it is the number of alerts nobody has triaged yet. + /// Focus is applied exactly as it is to `alerts`, so the sidebar badge matches the scope of the list it opens. + func newAlertCount(focus: FocusScope = .none) throws -> Int { var clause = "" var values: [SQLValue] = [] if !focus.bundleIDs.isEmpty { clause = " AND bundle_id IN (\(Array(repeating: "?", count: focus.bundleIDs.count).joined(separator: ",")))" values = focus.bundleIDs.map { .text($0) } } - return Int(try conn.query("SELECT COUNT(*) FROM alerts WHERE acknowledged = 0\(clause)", values) { $0.int(0) }.first ?? 0) + return Int(try conn.query("SELECT COUNT(*) FROM alerts WHERE triage_state = 'new'\(clause)", values) { + $0.int(0) + }.first ?? 0) } - func acknowledgeAlerts(ids: [Int64]?) throws { + /// Compatibility name retained for older callers; "unacknowledged" now means exactly the `new` state. + func unacknowledgedAlertCount(focus: FocusScope = .none) throws -> Int { + try newAlertCount(focus: focus) + } + + private static let alertIDBatchSize = 500 + + private func alertIDBatches(_ ids: [Int64]) -> [[Int64]] { + let unique = Array(Set(ids)).sorted() + return stride(from: 0, to: unique.count, by: Self.alertIDBatchSize).map { start in + Array(unique[start.. [(Int64, AlertTriageState)] { + let batches = alertIDBatches(ids) + guard !batches.isEmpty else { return [] } + var rows: [(Int64, AlertTriageState)] = [] + for batch in batches { + let placeholders = Array(repeating: "?", count: batch.count).joined(separator: ",") + rows += try conn.query("SELECT id, triage_state FROM alerts WHERE id IN (\(placeholders))", + batch.map { .int($0) }) { + ($0.int(0), AlertTriageState(rawValue: $0.text(1)) ?? .new) + } + } + let unique = batches.flatMap { $0 } + let found = Set(rows.map(\.0)) + if let missing = unique.first(where: { !found.contains($0) }) { throw AlertTriageError.alertNotFound(missing) } + return rows + } + + /// Validates every row before one update, so a mixed valid/invalid selection never partially advances. + func setAlertTriageState(_ state: AlertTriageState, ids: [Int64], at date: Date = Date()) throws { + let unique = Array(Set(ids)).sorted() + guard !unique.isEmpty else { return } + try conn.transaction { + let rows = try selectedAlertStates(ids: unique) + if let invalid = rows.first(where: { !$0.1.canTransition(to: state) }) { + throw AlertTriageError.invalidTransition(from: invalid.1, to: state) + } + for batch in alertIDBatches(unique) { + let placeholders = Array(repeating: "?", count: batch.count).joined(separator: ",") + try conn.run(""" + UPDATE alerts SET triage_state = ?, acknowledged = ?, triage_updated_at = ? + WHERE id IN (\(placeholders)) + """, [.text(state.rawValue), .int(state == .new ? 0 : 1), .double(date.timeIntervalSince1970)] + + batch.map { .int($0) }) + } + } + } + + func setAlertDisposition(_ disposition: AlertDisposition, ids: [Int64], at date: Date = Date()) throws { + let unique = Array(Set(ids)).sorted() + guard !unique.isEmpty else { return } + try conn.transaction { + _ = try selectedAlertStates(ids: unique) + for batch in alertIDBatches(unique) { + let placeholders = Array(repeating: "?", count: batch.count).joined(separator: ",") + try conn.run(""" + UPDATE alerts SET disposition = ?, triage_updated_at = ? WHERE id IN (\(placeholders)) + """, [.text(disposition.rawValue), .double(date.timeIntervalSince1970)] + batch.map { .int($0) }) + } + } + } + + func setAlertNote(_ note: String, id: Int64, at date: Date = Date()) throws { + guard note.count <= AlertRecord.maximumNoteLength else { + throw AlertTriageError.noteTooLong(maximum: AlertRecord.maximumNoteLength) + } + try conn.transaction { + _ = try selectedAlertStates(ids: [id]) + try conn.run("UPDATE alerts SET triage_note = ?, triage_updated_at = ? WHERE id = ?", + [.text(note), .double(date.timeIntervalSince1970), .int(id)]) + } + } + + /// Legacy acknowledgement is an additive adapter: it advances only `new` rows and cannot overwrite later work. + /// A nil selection means every new alert in the current Focus scope, updated as one set instead of materializing + /// an unbounded ID list. + func acknowledgeAlerts(ids: [Int64]?, focus: FocusScope = .none) throws { if let ids { - try conn.transaction { for id in ids { try conn.run("UPDATE alerts SET acknowledged = 1 WHERE id = ?", [.int(id)]) } } + let newIDs = try selectedAlertStates(ids: ids).filter { $0.1 == .new }.map(\.0) + try setAlertTriageState(.acknowledged, ids: newIDs) } else { - try conn.run("UPDATE alerts SET acknowledged = 1") + let bundleIDs = Array(Set(focus.bundleIDs)).sorted() + let clause: String + let values: [SQLValue] + if bundleIDs.isEmpty { + clause = "" + values = [] + } else { + clause = " AND bundle_id IN (\(Array(repeating: "?", count: bundleIDs.count).joined(separator: ",")))" + values = bundleIDs.map { .text($0) } + } + try conn.run(""" + UPDATE alerts SET triage_state = 'acknowledged', acknowledged = 1, triage_updated_at = ? + WHERE triage_state = 'new'\(clause) + """, [.double(Date().timeIntervalSince1970)] + values) } } diff --git a/Flowlight/UI/AgentInventoryModel.swift b/Flowlight/UI/AgentInventoryModel.swift new file mode 100644 index 00000000..9f6e34f4 --- /dev/null +++ b/Flowlight/UI/AgentInventoryModel.swift @@ -0,0 +1,535 @@ +import Foundation + +enum AgentHealthState: String, CaseIterable, Sendable { + case healthy = "Healthy" + case needsSetup = "Needs setup" + case limitedVisibility = "Limited visibility" + case attention = "Attention" + case inactive = "Inactive" + case notObserved = "Not observed" + + var sortRank: Int { + switch self { + case .attention: return 0 + case .needsSetup: return 1 + case .limitedVisibility: return 2 + case .healthy: return 3 + case .notObserved: return 4 + case .inactive: return 5 + } + } + + var title: String { + switch self { + case .healthy: return L("Healthy") + case .needsSetup: return L("Needs setup") + case .limitedVisibility: return L("Limited visibility") + case .attention: return L("Attention") + case .inactive: return L("Inactive") + case .notObserved: return L("Not observed") + } + } +} + +enum AgentRouteState: Sendable, Equatable { + case notRequested + case configured + case observed + case stale + case failed +} + +/// A deliberately small, injected view of inspection health. Raw errors and diagnoses stay out of the inventory so +/// they cannot expose a service name, path, URL, or other machine-specific detail. +struct AgentInspectionDiagnostics: Sendable, Equatable { + var available: Bool + var enabled: Bool + var running: Bool + var certificateTrusted: Bool + var setupFailed: Bool + + static let unavailable = AgentInspectionDiagnostics( + available: false, enabled: false, running: false, certificateTrusted: false, setupFailed: false + ) +} + +struct AgentInventoryPolicyCoverage: Sendable, Equatable { + var enabled: Bool + var enforcing: Bool + var uncoveredDestinations: Int +} + +struct AgentInventoryItem: Identifiable, Sendable, Equatable { + var id: String + /// Canonical identifier first, followed by every stable bundle/process/name alias used to merge this row. + var identities: [String] + /// Exact identifiers that can occur in stored traffic. Display names stay out so a same-named unrelated process + /// cannot enter History, Inspect, or Focus when this inventory row is selected. + var trafficIdentities: [String] + var name: String + var vendor: String? + var appPath: String + var health: AgentHealthState + var reasons: [String] + var installed: Bool + var configured: Bool + var observed: Bool + var autoDiscovered: Bool + var alwaysMonitored: Bool + var route: AgentRouteState + var policy: AgentInventoryPolicyCoverage + var unresolvedAlerts: Int + var summary: AgentSummary? + + var healthRank: Int { health.sortRank } + var trafficTotal: Int64 { summary?.total.total ?? 0 } + var providerNames: String { summary?.providers.map(\.name).joined(separator: ", ") ?? "" } + + /// A testable representation of everything the inventory presents as text. Paths and raw configuration values are + /// intentionally absent; `appPath` exists only to load the local application icon. + var displayText: String { ([name, health.rawValue] + reasons).joined(separator: " · ") } +} + +struct AgentInventoryInput: Sendable { + var installedApps: [InstalledApps.App] = [] + var traffic: [AgentSummary] = [] + var workspaces: [AgentWorkspace] = [] + var monitoredAgentNames: Set = [] + /// Keys may be a bundle/process identifier or the catalog display name. Matching is case-insensitive. + var routes: [String: AgentRouteState] = [:] + var inspection: AgentInspectionDiagnostics = .unavailable + var policies: [String: AgentPolicy] = [:] + var guardrailAgentIDs: [String] = [] + var alerts: [AlertRecord] = [] + var openAlertCounts: [AgentOpenAlertCount] = [] +} + +/// Builds the complete agent inventory from injected snapshots. It performs no disk, database, process, defaults, or +/// controller access, which keeps both the merge rules and the health explanations deterministic. +enum AgentInventoryBuilder { + private struct Evidence { + var known: KnownAgent? + var durableAgentIDs: Set = [] + var installed: [InstalledApps.App] = [] + var workspaces: [AgentWorkspace] = [] + var summaries: [AgentSummary] = [] + } + + static func build(_ input: AgentInventoryInput) -> [AgentInventoryItem] { + var evidence: [String: Evidence] = [:] + let individualAlertCounts = input.alerts.filter { !$0.state.isClosed }.map { + AgentOpenAlertCount(bundleID: $0.bundleID, appName: $0.appName, count: 1) + } + let alertGroups = input.openAlertCounts + individualAlertCounts + var openAlertGroupIDsByIdentity: [String: Set] = [:] + for (index, group) in alertGroups.enumerated() { + openAlertGroupIDsByIdentity[AgentCatalog.identityComparisonKey(group.bundleID), default: []].insert(index) + openAlertGroupIDsByIdentity[AgentCatalog.identityComparisonKey(group.appName), default: []].insert(index) + } + let monitoredIdentityKeys = Set(input.monitoredAgentNames.map(AgentCatalog.identityComparisonKey)) + var routeStatesByIdentity: [String: [AgentRouteState]] = [:] + for (identity, state) in input.routes { + routeStatesByIdentity[AgentCatalog.identityComparisonKey(identity), default: []].append(state) + } + + for app in input.installedApps { + guard !AgentCatalog.isBrowser(app.bundleID), + let known = AgentCatalog.knownAgent(bundleID: app.bundleID, appName: app.name) else { continue } + let key = catalogKey(known) + evidence[key, default: Evidence()].known = known + evidence[key, default: Evidence()].installed.append(app) + } + + for workspace in input.workspaces { + guard let known = catalogAgent(matching: workspace.agent, preferDisplayName: false) else { continue } + let key = catalogKey(known) + evidence[key, default: Evidence()].known = known + evidence[key, default: Evidence()].workspaces.append(workspace) + } + + // A persisted routing request is inventory evidence too. Keep it visible even before the agent is installed, + // configured in a discovered workspace, or observed in traffic, so a stale setup cannot disappear from view. + for name in input.monitoredAgentNames { + guard let known = catalogAgent(matching: name, preferDisplayName: true) else { continue } + evidence[catalogKey(known), default: Evidence()].known = known + } + + // Saved policies and agent-scoped guardrails are durable inventory evidence. Keep their agents visible outside + // the traffic window so deep links and editors never disappear just because an agent is quiet or no longer + // installed. Preserve every stored spelling so legacy aliases resolve deterministically. + let durableAgentIDs = input.policies.values.map(\.agentID) + input.guardrailAgentIDs + for agentID in durableAgentIDs { + guard !agentID.isEmpty else { continue } + let known = catalogAgent(matching: agentID, preferDisplayName: false) + let key = known.map(catalogKey) ?? "identity:\(AgentCatalog.identityComparisonKey(agentID))" + evidence[key, default: Evidence()].known = known + evidence[key, default: Evidence()].durableAgentIDs.insert(agentID) + } + + for summary in input.traffic { + guard !AgentCatalog.isBrowser(summary.bundleID), summary.isKnown || !summary.providers.isEmpty else { + continue + } + let known = AgentCatalog.knownAgent(bundleID: summary.bundleID, appName: summary.name) + let key = known.map(catalogKey) ?? "identity:\(AgentCatalog.identityComparisonKey(summary.bundleID))" + evidence[key, default: Evidence()].known = known + evidence[key, default: Evidence()].summaries.append(summary) + } + + return evidence.values.map { + item(from: $0, input: input, alertGroups: alertGroups, + openAlertGroupIDsByIdentity: openAlertGroupIDsByIdentity, + monitoredIdentityKeys: monitoredIdentityKeys, + routeStatesByIdentity: routeStatesByIdentity) + } + .sorted { + if $0.healthRank != $1.healthRank { return $0.healthRank < $1.healthRank } + let byName = $0.name.localizedCaseInsensitiveCompare($1.name) + return byName == .orderedSame ? $0.id < $1.id : byName == .orderedAscending + } + } + + private static func canonicalID(_ agent: KnownAgent) -> String { + agent.bundleIDs.sorted().first ?? agent.processNames.sorted().first ?? agent.name.lowercased() + } + + private static func mergedSummary(from evidence: Evidence) -> AgentSummary? { + guard !evidence.summaries.isEmpty else { return nil } + let known = evidence.known + let preferredIDs = (known?.bundleIDs.sorted() ?? []) + + (known?.processNames.sorted() ?? []) + + evidence.installed.map(\.bundleID).sorted() + let rank: (AgentSummary) -> (Int, String) = { summary in + let index = preferredIDs.firstIndex { + $0.caseInsensitiveCompare(summary.bundleID) == .orderedSame + } ?? preferredIDs.count + return (index, summary.bundleID.lowercased()) + } + let ordered = evidence.summaries.sorted { rank($0) < rank($1) } + let canonical = ordered[0] + var ai = FlowCounters() + var egress = FlowCounters() + var providers: [String: FlowCounters] = [:] + var destinations: [String: AgentDestination] = [:] + var tools: [String: AgentTool] = [:] + var alerts = 0 + + for summary in ordered { + ai += summary.ai + egress += summary.egress + alerts += summary.alerts + for provider in summary.providers { + providers[provider.name, default: FlowCounters()] += provider.counters + } + for destination in summary.destinations { + var merged = destinations[destination.id] ?? destination + if destinations[destination.id] != nil { + merged.counters += destination.counters + merged.protocols = Array(Set(merged.protocols + destination.protocols)).sorted() + merged.ports = mergedPorts(merged.ports, destination.ports) + merged.via = Array(Set(merged.via + destination.via)).sorted() + if merged.provider == nil { merged.provider = destination.provider } + merged.hasHostname = merged.hasHostname || destination.hasHostname + } + destinations[destination.id] = merged + } + for tool in summary.tools { + var merged = tools[tool.id] ?? tool + if tools[tool.id] != nil { + merged.counters += tool.counters + merged.destinations += tool.destinations + } + tools[tool.id] = merged + } + } + + let sortedDestinations = destinations.values.sorted { + let lhsAI = $0.provider != nil + let rhsAI = $1.provider != nil + if lhsAI != rhsAI { return lhsAI } + if $0.counters.total != $1.counters.total { return $0.counters.total > $1.counters.total } + return $0.id < $1.id + } + return AgentSummary( + bundleID: known.map(canonicalID) ?? canonical.bundleID, + name: known?.name ?? canonical.name, + vendor: known?.vendor ?? ordered.compactMap(\.vendor).sorted().first, + isKnown: known != nil || ordered.contains(where: \.isKnown), + appPath: canonical.appPath.nilIfEmpty ?? ordered.map(\.appPath).filter { !$0.isEmpty }.sorted().first ?? "", + ai: ai, + egress: egress, + providers: providers.sorted { + $0.value.total == $1.value.total ? $0.key < $1.key : $0.value.total > $1.value.total + }.map { ($0.key, $0.value) }, + destinations: sortedDestinations, + tools: tools.values.sorted { + $0.counters.total == $1.counters.total ? $0.id < $1.id : $0.counters.total > $1.counters.total + }, + alerts: alerts + ) + } + + private static func mergedPorts(_ lhs: String, _ rhs: String) -> String { + let ports = Set((lhs + "," + rhs).split(separator: ",").map { + $0.trimmingCharacters(in: .whitespaces) + }.filter { !$0.isEmpty }) + return ports.sorted { $0.count == $1.count ? $0 < $1 : $0.count < $1.count } + .joined(separator: ", ") + } + + private static func item(from evidence: Evidence, input: AgentInventoryInput, + alertGroups: [AgentOpenAlertCount], + openAlertGroupIDsByIdentity: [String: Set], + monitoredIdentityKeys: Set, + routeStatesByIdentity: [String: [AgentRouteState]]) -> AgentInventoryItem { + let known = evidence.known + let summary = mergedSummary(from: evidence) + let installed = evidence.installed.sorted { $0.bundleID < $1.bundleID }.first + let configured = !evidence.workspaces.isEmpty + let observed = summary.map { !$0.total.isEmpty } ?? false + let durableAgentIDs = evidence.durableAgentIDs.sorted() + let rawName = known?.name ?? summary?.name ?? installed?.name ?? durableAgentIDs.first + let id = known.map(canonicalID) ?? summary?.bundleID ?? installed?.bundleID + ?? durableAgentIDs.first ?? rawName?.lowercased() ?? "detected-agent" + let name = rawName.map { safeDisplayName($0, fallback: id) } ?? L("Detected agent") + let aliases = identityAliases(id: id, name: name, known: known, durableAgentIDs: durableAgentIDs, + installed: evidence.installed, summaries: evidence.summaries) + let identityPriority = identityPriority(id: id, name: name, known: known, + durableAgentIDs: durableAgentIDs, + installed: evidence.installed, summaries: evidence.summaries) + let trafficIdentities = trafficIdentityPriority( + id: id, known: known, durableAgentIDs: durableAgentIDs, + installed: evidence.installed, summaries: evidence.summaries + ) + let monitored = !aliases.isDisjoint(with: monitoredIdentityKeys) + let route = routeState(aliases: aliases, routes: routeStatesByIdentity, monitored: monitored) + let policy = policyCoverage(identityPriority: identityPriority, summary: summary, policies: input.policies) + let matchingAlertGroups = aliases.reduce(into: Set()) { groupIDs, identity in + groupIDs.formUnion(openAlertGroupIDsByIdentity[identity] ?? []) + } + let unresolved = matchingAlertGroups.reduce(0) { $0 + alertGroups[$1].count } + let health = health( + observed: observed, + installed: installed != nil, + configured: configured, + monitored: monitored, + route: route, + inspection: input.inspection, + policy: policy, + unresolvedAlerts: unresolved + ) + let reasons = reasons( + observed: observed, + installed: installed != nil, + configured: configured, + monitored: monitored, + route: route, + inspection: input.inspection, + policy: policy, + unresolvedAlerts: unresolved + ) + return AgentInventoryItem( + id: id, + identities: identityPriority, + trafficIdentities: trafficIdentities, + name: name, + vendor: known?.vendor ?? summary?.vendor, + appPath: summary?.appPath.nilIfEmpty ?? installed?.path ?? "", + health: health, + reasons: reasons, + installed: installed != nil, + configured: configured, + observed: observed, + autoDiscovered: known == nil && summary != nil, + alwaysMonitored: monitored, + route: route, + policy: policy, + unresolvedAlerts: unresolved, + summary: summary + ) + } + + private static func catalogKey(_ agent: KnownAgent) -> String { + "known:\(agent.name.lowercased())" + } + + private static func catalogAgent(matching value: String, preferDisplayName: Bool) -> KnownAgent? { + let exactName = AgentCatalog.agents.first { $0.name == value } + let exactIdentifier = AgentCatalog.agents.first { agent in + agent.bundleIDs.contains(value) || agent.processNames.contains(value) + } + let unambiguous = AgentCatalog.knownAgent(identity: value) + return preferDisplayName + ? exactName ?? exactIdentifier ?? unambiguous + : exactIdentifier ?? exactName ?? unambiguous + } + + private static func safeDisplayName(_ candidate: String, fallback: String) -> String { + func safe(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed.count <= 80, + !trimmed.contains("/"), !trimmed.contains("\\"), !trimmed.contains("://"), + trimmed.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { return nil } + return trimmed + } + return safe(candidate) ?? safe(fallback) ?? L("Detected agent") + } + + private static func identityAliases(id: String, name: String, known: KnownAgent?, + durableAgentIDs: [String], installed: [InstalledApps.App], + summaries: [AgentSummary]) -> Set { + var values = [id, name] + values.append(contentsOf: durableAgentIDs) + values.append(contentsOf: known?.bundleIDs.sorted() ?? []) + values.append(contentsOf: known?.processNames.sorted() ?? []) + for app in installed { + values.append(app.bundleID) + values.append(app.name) + } + for summary in summaries { + values.append(summary.bundleID) + values.append(summary.name) + } + return Set(values.map(AgentCatalog.identityComparisonKey)) + } + + private static func identityPriority(id: String, name: String, known: KnownAgent?, + durableAgentIDs: [String], installed: [InstalledApps.App], + summaries: [AgentSummary]) -> [String] { + var candidates = [id] + candidates.append(contentsOf: durableAgentIDs) + candidates.append(contentsOf: known?.bundleIDs.sorted() ?? []) + candidates.append(contentsOf: known?.processNames.sorted() ?? []) + candidates.append(contentsOf: summaries.map(\.bundleID).sorted()) + if let knownName = known?.name { candidates.append(knownName) } + candidates.append(name) + candidates.append(contentsOf: installed.map(\.bundleID).sorted()) + candidates.append(contentsOf: installed.map(\.name).sorted()) + candidates.append(contentsOf: summaries.map(\.name).sorted()) + var seen = Set() + return candidates.filter { seen.insert($0).inserted } + } + + private static func trafficIdentityPriority( + id: String, + known: KnownAgent?, + durableAgentIDs: [String], + installed: [InstalledApps.App], + summaries: [AgentSummary] + ) -> [String] { + var candidates = [id] + candidates.append(contentsOf: known?.bundleIDs.sorted() ?? []) + candidates.append(contentsOf: known?.processNames.sorted() ?? []) + if known == nil { candidates.append(contentsOf: durableAgentIDs) } + candidates.append(contentsOf: installed.map(\.bundleID).sorted()) + candidates.append(contentsOf: summaries.map(\.bundleID).sorted()) + var seen = Set() + return candidates.filter { !$0.isEmpty && seen.insert($0).inserted } + } + + private static func routeState(aliases: Set, routes: [String: [AgentRouteState]], + monitored: Bool) -> AgentRouteState { + let matches = aliases.flatMap { routes[$0] ?? [] } + if matches.contains(.failed) { return .failed } + if matches.contains(.stale) { return .stale } + if matches.contains(.observed) { return .observed } + if matches.contains(.configured) || monitored { return .configured } + return .notRequested + } + + private static func policyCoverage(identityPriority: [String], summary: AgentSummary?, + policies: [String: AgentPolicy]) -> AgentInventoryPolicyCoverage { + guard let policy = AgentPolicy.resolve(policies.values, identities: identityPriority), + policy.enabled else { + return AgentInventoryPolicyCoverage(enabled: false, enforcing: false, uncoveredDestinations: 0) + } + let uncovered = summary?.destinations.filter { + !policy.allows(host: $0.hasHostname ? $0.label : "", ip: $0.ip, isAIProvider: $0.provider != nil) + }.count ?? 0 + return AgentInventoryPolicyCoverage(enabled: true, enforcing: policy.enforce, + uncoveredDestinations: uncovered) + } + + private static func health(observed: Bool, installed: Bool, configured: Bool, monitored: Bool, + route: AgentRouteState, inspection: AgentInspectionDiagnostics, + policy: AgentInventoryPolicyCoverage, unresolvedAlerts: Int) -> AgentHealthState { + if unresolvedAlerts > 0 || policy.uncoveredDestinations > 0 || route == .failed || route == .stale { + return .attention + } + guard observed else { + return configured || monitored || route != .notRequested ? .notObserved : installed ? .inactive : .notObserved + } + if inspection.available && (inspection.setupFailed + || inspection.enabled && (!inspection.running || !inspection.certificateTrusted)) { + return .needsSetup + } + guard inspection.available, inspection.enabled, inspection.running, inspection.certificateTrusted, + route == .observed else { return .limitedVisibility } + return .healthy + } + + /// Reasons are appended in a fixed evidence → route → inspection → policy → alerts order. None interpolate raw + /// app paths, workspace roots, endpoints, diagnoses, or alert details. + private static func reasons(observed: Bool, installed: Bool, configured: Bool, monitored: Bool, + route: AgentRouteState, inspection: AgentInspectionDiagnostics, + policy: AgentInventoryPolicyCoverage, unresolvedAlerts: Int) -> [String] { + var reasons: [String] = [] + if observed { + reasons.append(L("Traffic was observed in the selected window.")) + } else if configured { + reasons.append(L("Configuration was found, but no traffic was observed in the selected window.")) + } else if installed { + reasons.append(L("Installed on this Mac, with no traffic in the selected window.")) + } else { + reasons.append(L("No traffic was observed in the selected window.")) + } + + switch route { + case .notRequested: break + case .configured: + reasons.append(L("Always Monitor is configured, but no current route was observed.")) + case .observed: + reasons.append(L("Its route through Flowlight was observed.")) + case .stale: + reasons.append(L("The saved Flowlight route was not observed in this window.")) + case .failed: + reasons.append(L("Flowlight could not establish the requested route.")) + } + + if observed { + if !inspection.available { + reasons.append(L("HTTPS inspection is unavailable in this build; only connection metadata is visible.")) + } else if !inspection.enabled { + reasons.append(L("HTTPS inspection is off; request contents are not visible.")) + } else if !inspection.certificateTrusted { + reasons.append(L("The HTTPS inspection certificate is not trusted.")) + } else if inspection.setupFailed || !inspection.running { + reasons.append(L("HTTPS inspection is enabled, but its proxy is not ready.")) + } else if route != .observed { + reasons.append(L("No inspected requests were observed in this window.")) + } else { + reasons.append(L("HTTPS inspection observed this agent's traffic.")) + } + } + + if policy.enabled { + if policy.uncoveredDestinations > 0 { + reasons.append(policy.uncoveredDestinations == 1 + ? L("One observed destination is outside its policy.") + : L("%lld observed destinations are outside its policy.", policy.uncoveredDestinations)) + } else { + reasons.append(policy.enforcing + ? L("Its enforcing destination policy covers the observed traffic.") + : L("Its reporting destination policy covers the observed traffic.")) + } + } else if observed || configured || monitored { + reasons.append(L("No agent destination policy is enabled.")) + } + + if unresolvedAlerts > 0 { + reasons.append(unresolvedAlerts == 1 ? L("One unresolved alert needs review.") + : L("%lld unresolved alerts need review.", unresolvedAlerts)) + } + return reasons + } +} diff --git a/Flowlight/UI/AgentsModel.swift b/Flowlight/UI/AgentsModel.swift index 6ca04252..092b0c8e 100644 --- a/Flowlight/UI/AgentsModel.swift +++ b/Flowlight/UI/AgentsModel.swift @@ -49,7 +49,6 @@ struct AgentSummary: Identifiable, Sendable, Equatable { static let largeUploadBytes: Int64 = 50_000_000 var hasLargeUpload: Bool { egress.bytesOut >= Self.largeUploadBytes } var hasUnnamedHost: Bool { otherDestinations.contains { $0.isUnnamed && !IPOwnerLookup.isLocalNetwork($0.ip) } } - var riskScore: Int { sensitiveProtocols.count * 10 + alerts + (hasUnnamedHost ? 3 : 0) + (hasLargeUpload ? 5 : 0) } static func == (a: AgentSummary, b: AgentSummary) -> Bool { a.bundleID == b.bundleID && a.ai == b.ai && a.egress == b.egress && a.alerts == b.alerts && a.tools == b.tools diff --git a/Flowlight/UI/AgentsView.swift b/Flowlight/UI/AgentsView.swift index c0fa3da4..adec99b7 100644 --- a/Flowlight/UI/AgentsView.swift +++ b/Flowlight/UI/AgentsView.swift @@ -1,5 +1,33 @@ import SwiftUI +struct AgentPolicyDeepLinkState: Equatable { + var request: AgentPolicyRequest? + + func section(for agentID: String) -> AgentPolicySection? { + section(for: [agentID]) + } + + func section(for identities: [String]) -> AgentPolicySection? { + guard let request, + identities.contains(where: { AgentCatalog.sameAgentIdentity($0, request.agentID) }) else { + return nil + } + return request.section + } + + func guardrailID(for identities: [String]) -> UUID? { + guard let request, request.section == .guardrails, + identities.contains(where: { AgentCatalog.sameAgentIdentity($0, request.agentID) }) else { + return nil + } + return request.guardrailID + } + + mutating func consume() { + request = nil + } +} + enum AgentWindow: String, CaseIterable, Identifiable { case hour, day, week var id: String { rawValue } @@ -14,36 +42,43 @@ enum AgentWindow: String, CaseIterable, Identifiable { var granularity: Granularity { self == .week ? .hour : .minute } } -/// AI agents on this Mac: what they talk to besides their model provider, and how risky that looks. +/// Every AI agent Flowlight can account for on this Mac, whether installed, configured, or observed. struct AgentsView: View { @EnvironmentObject var monitor: TrafficMonitor @EnvironmentObject var nav: AppNavigation @EnvironmentObject var focus: FocusStore @AppStorage("agents.window") private var window: AgentWindow = .day - @State private var agents: [AgentSummary] = [] - @State private var agentAlerts = 0 + @State private var trafficAgents: [AgentSummary] = [] + @State private var inventory: [AgentInventoryItem] = [] + @State private var installedApps: [InstalledApps.App] = [] + @State private var openAlertCounts: [AgentOpenAlertCount] = [] @State private var policies: [String: AgentPolicy] = [:] @State private var toolUsage: [String: [ToolUsage]] = [:] @State private var toolActivity: [String: [ToolActivity]] = [:] @State private var mcpServers: [String: [MCPServerSummary]] = [:] @State private var profiles: [String: AgentProfile] = [:] - /// Agents Flowlight actually decrypted something from in this window — not the same question as - /// whether inspection is on, since an agent has to be routed through the proxy to be seen at all. + /// Agents Flowlight actually decrypted something from in this window — not the same question as whether + /// inspection is on, since an agent has to be routed through the proxy to be seen at all. @State private var inspectedAgents: Set = [] @ObservedObject private var workspaceStore = AgentWorkspaceStore.shared - @State private var selection: AgentSummary.ID? - @State private var sortOrder = [KeyPathComparator(\AgentSummary.riskScore, order: .reverse)] + @State private var selection: AgentInventoryItem.ID? + @State private var policyDeepLink = AgentPolicyDeepLinkState() + @State private var sortOrder = [ + KeyPathComparator(\AgentInventoryItem.healthRank), + KeyPathComparator(\AgentInventoryItem.name), + ] @State private var loaded = false + @State private var inventoryRebuildTask: Task? + @State private var lastInspectionInventoryState: InspectionInventoryState? + @State private var lastInstalledAppScan = Date.distantPast - private var selected: AgentSummary? { agents.first { $0.id == selection } ?? agents.first } + private var selectedItem: AgentInventoryItem? { inventory.first { $0.id == selection } ?? inventory.first } /// The agents "always monitor" could offer to keep routed: known agents Flowlight can edit safely (they have a - /// `configRecipe`) that are talking to an LLM provider, each paired with whether any of their traffic is actually - /// being decrypted this window. The banner turns this into a suggestion or a "still not routed" note by comparing - /// it against which agents are already monitored. + /// `configRecipe`) that are talking to an LLM provider, each paired with whether any traffic was decrypted. private var monitorCandidates: [AlwaysMonitorBanner.Candidate] { var routedByName: [String: Bool] = [:] - for agent in agents { + for agent in trafficAgents { guard let known = AgentCatalog.knownAgent(bundleID: agent.bundleID, appName: agent.name), known.configRecipe != nil, !agent.providers.isEmpty else { continue } routedByName[known.name] = (routedByName[known.name] ?? false) || inspectedAgents.contains(agent.bundleID) @@ -53,13 +88,10 @@ struct AgentsView: View { } var body: some View { - GeometryReader { geometry in - content(height: geometry.size.height) - } + GeometryReader { geometry in content(height: geometry.size.height) } } - /// In a short window the summary tiles and the description give way to the table and the agent's detail, which - /// are what the view is for. Without this the header is squeezed behind the title bar. + /// In a short window the summary tiles and description give way to the inventory and selected agent detail. private func content(height: CGFloat) -> some View { let roomy = height > 680 return VStack(alignment: .leading, spacing: 12) { @@ -67,7 +99,7 @@ struct AgentsView: View { demo: DemoData.isEnabled && UserDefaults.standard.bool(forKey: "FLDemoBanner")) HStack { if roomy { - Text(L("Observed AI agent activity, including destinations outside each agent's model provider.")) + Text(L("Installed, configured, and observed AI agents, with explainable setup and visibility health.")) .foregroundStyle(.secondary) } Spacer() @@ -78,144 +110,420 @@ struct AgentsView: View { } .fixedSize(horizontal: false, vertical: true) if roomy { tiles.fixedSize(horizontal: false, vertical: true) } - if agents.isEmpty && loaded { + if inventory.isEmpty && loaded { ContentUnavailableView { - Label(L("No AI agents seen"), systemImage: "sparkles") + Label(L("No AI agents found"), systemImage: "sparkles") } description: { - Text(L("Flowlight recognizes supported agents such as Claude Code, Codex, Cursor and Ollama. It also classifies non-browser apps that contact a known LLM API provider.")) + Text(L("Flowlight inventories supported apps and configured command-line agents, and discovers non-browser clients that contact a known AI provider.")) } .frame(maxHeight: .infinity) } else { - // Fit the table to its rows so the selected agent's detail gets the rest of the window. - table.frame(minHeight: 180, maxHeight: max(180, CGFloat(agents.count) * 38 + 44)) - if let selected { - AgentDetail(agent: selected, window: window, toolCalls: toolUsage[selected.bundleID] ?? [], - activity: toolActivity[selected.bundleID] ?? [], servers: mcpServers[selected.bundleID] ?? [], - profile: profiles[selected.bundleID], inspected: inspectedAgents.contains(selected.bundleID), - workspaces: workspaceStore.workspaces(bundleID: selected.bundleID, name: selected.name), - policy: policies[selected.bundleID] ?? AgentPolicy(agentID: selected.bundleID, enabled: false)) { policy in + table.frame(minHeight: 180, maxHeight: max(180, CGFloat(inventory.count) * 42 + 44)) + if let item = selectedItem { + let selected = detailSummary(for: item) + AgentDetail(agent: selected, window: window, toolCalls: toolUsage[item.id] ?? [], + activity: toolActivity[item.id] ?? [], servers: mcpServers[item.id] ?? [], + profile: profiles[item.id], inspected: isInspected(item.identities), + workspaces: workspaceStore.workspaces(bundleID: item.id, name: item.name), + policy: policy(for: item), historyFilter: historyFilter(for: item), + requestedPolicySection: policyDeepLink.section(for: item.identities), + requestedGuardrailID: policyDeepLink.guardrailID(for: item.identities), + consumeRequestedPolicySection: { policyDeepLink.consume() }) { policy in policies[policy.agentID] = policy monitor.savePolicy(policy) + rebuildInventory() } + .id(item.id) } } } .padding() .navigationTitle(L("AI Agents")) + .onChange(of: nav.agentPolicyRequest, initial: true) { _, request in + guard let request else { return } + policyDeepLink.request = request + resolvePolicyDeepLink() + nav.agentPolicyRequest = nil + } + .onChange(of: workspaceStore.workspaces) { _, _ in rebuildInventory() } + .onReceive(monitor.inspection.objectWillChange) { _ in scheduleInventoryRebuildIfNeeded() } + .onDisappear { + inventoryRebuildTask?.cancel() + inventoryRebuildTask = nil + } .task(id: LoadKey(window: window, version: monitor.dataVersion, focus: focus.scope)) { await load() } } private struct LoadKey: Equatable { var window: AgentWindow; var version: Int; var focus: FocusScope } private var tiles: some View { - let ai = agents.reduce(FlowCounters()) { var c = $0; c += $1.ai; return c } - let egress = agents.reduce(Int64(0)) { $0 + $1.egress.bytesOut } - let risky = agents.filter { !$0.sensitiveProtocols.isEmpty }.count + let active = inventory.filter(\.observed).count + let healthy = inventory.filter { $0.health == .healthy }.count + let attention = inventory.filter { $0.health == .attention || $0.health == .needsSetup }.count + let limited = inventory.filter { $0.health == .limitedVisibility }.count return HStack(spacing: 12) { - StatTile(title: L("Agents active"), value: "\(agents.count)", systemImage: "sparkles") - StatTile(title: L("AI API traffic"), value: ByteFormat.string(ai.total), systemImage: "brain") - StatTile(title: L("Uploaded to other hosts"), value: ByteFormat.string(egress), systemImage: "arrow.up.forward.app", - tint: TrafficColors.outbound) - StatTile(title: L("Using sensitive channels"), value: "\(risky)", systemImage: "exclamationmark.shield", - tint: risky > 0 ? TrafficColors.anomaly : .secondary) - StatTile(title: L("Agent alerts"), value: "\(agentAlerts)", systemImage: "bell.badge", - tint: agentAlerts > 0 ? TrafficColors.anomaly : .secondary) + StatTile(title: L("Agents inventoried"), value: "\(inventory.count)", systemImage: "sparkles") + StatTile(title: L("Active in window"), value: "\(active)", systemImage: "waveform.path.ecg") + StatTile(title: L("Healthy"), value: "\(healthy)", systemImage: "checkmark.circle", + tint: healthy > 0 ? FL.good : .secondary) + StatTile(title: L("Needs attention"), value: "\(attention)", systemImage: "exclamationmark.triangle", + tint: attention > 0 ? TrafficColors.anomaly : .secondary) + StatTile(title: L("Limited visibility"), value: "\(limited)", systemImage: "eye.slash", + tint: limited > 0 ? FL.warning : .secondary) } } private var table: some View { - let maxAI = agents.map(\.ai.total).max() ?? 1 - return Table(agents.sorted(using: sortOrder), selection: $selection, sortOrder: $sortOrder) { - TableColumn(L("Agent"), value: \.name) { agent in + Table(inventory.sorted(using: sortOrder), selection: $selection, sortOrder: $sortOrder) { + TableColumn(L("Agent"), value: \.name) { item in HStack(spacing: 8) { - AppIconView(path: agent.appPath, size: 20) + AppIconView(path: item.appPath, size: 20) VStack(alignment: .leading, spacing: 1) { - Text(agent.name).lineLimit(1) - Text(agent.vendor ?? L("Detected: calls an AI API")).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + Text(item.name).lineLimit(1) + Text(item.vendor ?? L("Detected: calls an AI API")) + .font(.caption2).foregroundStyle(.secondary).lineLimit(1) } } - .help(agent.bundleID) + .help(item.name) } - .width(min: 150, ideal: 190) - TableColumn(L("AI providers")) { agent in - Text(agent.providers.map(\.name).joined(separator: ", ")).lineLimit(1).foregroundStyle(.secondary) + .width(125) + TableColumn(L("Health"), value: \.healthRank) { AgentHealthBadge(state: $0.health) } + .width(95) + TableColumn(L("Why")) { item in + Text(item.reasons.first ?? "").lineLimit(2).foregroundStyle(.secondary) + .help(item.reasons.joined(separator: "\n")) } - .width(min: 80, ideal: 130) - TableColumn(L("AI traffic"), value: \.aiTotal) { agent in - VStack(alignment: .leading, spacing: 3) { - Text(ByteFormat.string(agent.ai.total)).monospacedDigit() - if agent.ai.total > 0 { - TrafficBar(fraction: Double(agent.ai.total) / Double(max(maxAI, 1)), color: FL.received) - } - } + .width(130) + TableColumn(L("AI providers")) { item in + Text(item.providerNames.isEmpty ? "–" : item.providerNames).lineLimit(1).foregroundStyle(.secondary) + .help(item.providerNames) + } + .width(65) + TableColumn(L("Traffic"), value: \.trafficTotal) { item in + Text(item.observed ? ByteFormat.string(item.trafficTotal) : "–").monospacedDigit() } - .width(min: 96, ideal: 120) - TableColumn(L("Other hosts"), value: \.otherCount) { Text("\($0.otherCount)").monospacedDigit() }.width(70) - TableColumn(L("Sent elsewhere"), value: \.egressOut) { agent in - Text(ByteFormat.string(agent.egress.bytesOut)).monospacedDigit() - .foregroundStyle(agent.egressShare > 0.5 && agent.egress.bytesOut > 1_000_000 ? TrafficColors.outbound : .primary) - .help(L("%@ of this agent's uploads went to non-AI hosts", ShareFormat.string(agent.egressShare))) + .width(65) + TableColumn(L("Policy")) { item in + Text(policyLabel(item.policy)).font(.caption).foregroundStyle(item.policy.uncoveredDestinations > 0 + ? TrafficColors.anomaly : .secondary) } - .width(90) - TableColumn(L("Allowlist")) { agent in AllowlistStatus(agent: agent, policy: policies[agent.bundleID]) } - .width(min: 80, ideal: 110) - TableColumn(L("Risk"), value: \.riskScore) { agent in RiskBadges(agent: agent) } - .width(min: 90, ideal: 160) - TableColumn(L("Alerts"), value: \.alerts) { agent in - Text(agent.alerts == 0 ? "–" : "\(agent.alerts)").monospacedDigit() - .foregroundStyle(agent.alerts > 0 ? TrafficColors.anomaly : .secondary) + .width(60) + TableColumn(L("Alerts"), value: \.unresolvedAlerts) { item in + Text(item.unresolvedAlerts == 0 ? "–" : "\(item.unresolvedAlerts)").monospacedDigit() + .foregroundStyle(item.unresolvedAlerts > 0 ? TrafficColors.anomaly : .secondary) } - .width(50) + .width(40) } - .contextMenu(forSelectionType: AgentSummary.ID.self) { ids in - if let id = ids.first { - Button(L("Show Traffic in History")) { nav.showReport(filter: TrafficFilter(bundleID: id), granularity: window.granularity) } - InspectMenuItems(app: (id, agents.first { $0.bundleID == id }?.name ?? id)) - FocusMenuItems(app: (id, agents.first { $0.bundleID == id }?.name ?? id)) + .contextMenu(forSelectionType: AgentInventoryItem.ID.self) { ids in + if let id = ids.first, let item = inventory.first(where: { $0.id == id }) { + Button(L("Show Traffic in History")) { + nav.showReport(filter: historyFilter(for: item), granularity: window.granularity) + } + InspectMenuItems(filter: historyFilter(for: item), app: (id, item.name)) + focusMenu(for: item) Divider() - RuleMenuItems(app: (id, agents.first { $0.bundleID == id }?.name ?? id)) - if let known = AgentCatalog.knownAgent(bundleID: id, appName: agents.first { $0.bundleID == id }?.name ?? id), - known.configRecipe != nil { + RuleMenuItems(app: (id, item.name)) + if let known = AgentCatalog.knownAgent(bundleID: id, appName: item.name), known.configRecipe != nil { Divider() if monitor.inspection.monitoredAgents.contains(known.name) { - Button(L("Stop Monitoring %@", known.name)) { monitor.inspection.setAlwaysMonitor(false, agent: known.name) } + Button(L("Stop Monitoring %@", known.name)) { + monitor.inspection.setAlwaysMonitor(false, agent: known.name) + rebuildInventory() + } } else { - Button(L("Always Monitor %@", known.name)) { monitor.inspection.setAlwaysMonitor(true, agent: known.name) } + Button(L("Always Monitor %@", known.name)) { + monitor.inspection.setAlwaysMonitor(true, agent: known.name) + rebuildInventory() + } } } } } primaryAction: { ids in - if let id = ids.first { nav.showReport(filter: TrafficFilter(bundleID: id), granularity: window.granularity) } + if let id = ids.first, let item = inventory.first(where: { $0.id == id }) { + nav.showReport(filter: historyFilter(for: item), granularity: window.granularity) + } + } + } + + @ViewBuilder private func focusMenu(for item: AgentInventoryItem) -> some View { + let targets = item.trafficIdentities.compactMap { FocusTarget.app($0, name: item.name) } + if !targets.isEmpty { + let allFocused = targets.allSatisfy(focus.contains) + Button(allFocused ? L("Remove %@ from Focus", item.name) : L("Focus on %@", item.name)) { + if allFocused { + targets.forEach(focus.remove) + } else { + targets.forEach(focus.add) + } + } + } + } + + private func policyLabel(_ coverage: AgentInventoryPolicyCoverage) -> String { + guard coverage.enabled else { return L("Not set") } + if coverage.uncoveredDestinations > 0 { return L("Review") } + return coverage.enforcing ? L("Enforcing") : L("Reporting") + } + + private func inspectionDiagnostics() -> AgentInspectionDiagnostics { + #if APP_STORE + return .unavailable + #else + let inspection = monitor.inspection + return AgentInspectionDiagnostics( + available: true, + enabled: inspection.enabled, + running: inspection.running, + certificateTrusted: inspection.trusted, + setupFailed: inspection.enabled && (inspection.lastError != nil + || inspection.diagnosis != nil && !inspection.diagnosisIsGood) + ) + #endif + } + + private func isInspected(_ identities: [String]) -> Bool { + inspectedAgents.contains { observed in + identities.contains { AgentCatalog.sameAgentIdentity($0, observed) } + } + } + + private func routeStates() -> [String: AgentRouteState] { + var routes: [String: AgentRouteState] = [:] + let inspection = monitor.inspection + let requestedButUnobserved: AgentRouteState + if inspection.enabled && (inspection.lastError != nil + || inspection.diagnosis != nil && !inspection.diagnosisIsGood) { + requestedButUnobserved = .failed + } else if inspection.enabled && inspection.running { + requestedButUnobserved = .stale + } else { + requestedButUnobserved = .configured + } + for agent in trafficAgents { + guard let known = AgentCatalog.knownAgent(bundleID: agent.bundleID, appName: agent.name) else { + if isInspected([agent.bundleID, agent.name]) { routes[agent.bundleID] = .observed } + continue + } + let identities = [agent.bundleID, agent.name, known.name] + known.bundleIDs + known.processNames + if isInspected(identities) { + routes[known.name] = .observed + } else if inspection.monitoredAgents.contains(known.name) { + routes[known.name] = requestedButUnobserved + } + } + for name in inspection.monitoredAgents where routes[name] == nil { + routes[name] = requestedButUnobserved + } + return routes + } + + private struct InspectionInventoryState: Equatable { + var enabled: Bool + var running: Bool + var trusted: Bool + var lastError: String? + var diagnosis: String? + var diagnosisIsGood: Bool + var monitoredAgents: [String] + } + + private var inspectionInventoryState: InspectionInventoryState { + let inspection = monitor.inspection + return InspectionInventoryState( + enabled: inspection.enabled, + running: inspection.running, + trusted: inspection.trusted, + lastError: inspection.lastError, + diagnosis: inspection.diagnosis, + diagnosisIsGood: inspection.diagnosisIsGood, + monitoredAgents: inspection.monitoredAgents.sorted() + ) + } + + private func scheduleInventoryRebuildIfNeeded() { + inventoryRebuildTask?.cancel() + inventoryRebuildTask = Task { @MainActor in + await Task.yield() + guard !Task.isCancelled else { return } + let state = inspectionInventoryState + guard state != lastInspectionInventoryState else { + inventoryRebuildTask = nil + return + } + lastInspectionInventoryState = state + rebuildInventory() + inventoryRebuildTask = nil } } + private func inventorySnapshot() -> [AgentInventoryItem] { + AgentInventoryBuilder.build(AgentInventoryInput( + installedApps: installedApps, + traffic: trafficAgents, + workspaces: workspaceStore.workspaces, + monitoredAgentNames: monitor.inspection.monitoredAgents, + routes: routeStates(), + inspection: inspectionDiagnostics(), + policies: policies, + guardrailAgentIDs: monitor.guardrails.guardrails.map(\.agent), + openAlertCounts: openAlertCounts + )) + } + + private func rebuildInventory() { + publishInventory(inventorySnapshot()) + } + + private func publishInventory(_ snapshot: [AgentInventoryItem]) { + inventory = snapshot + let ids = Set(snapshot.map(\.id)) + if selection == nil || !ids.contains(selection!) { selection = snapshot.first?.id } + resolvePolicyDeepLink() + } + + private func resolvePolicyDeepLink() { + guard policyDeepLink.request != nil, + let item = inventory.first(where: { policyDeepLink.section(for: $0.identities) != nil }) else { return } + selection = item.id + } + + private func detailSummary(for item: AgentInventoryItem) -> AgentSummary { + item.summary ?? AgentSummary( + bundleID: item.id, name: item.name, vendor: item.vendor, isKnown: !item.autoDiscovered, + appPath: item.appPath, ai: FlowCounters(), egress: FlowCounters(), providers: [], + destinations: [], tools: [], alerts: item.unresolvedAlerts + ) + } + + private func policy(for item: AgentInventoryItem) -> AgentPolicy { + AgentPolicy.resolve(policies.values, identities: item.identities) + ?? AgentPolicy(agentID: item.id, enabled: false) + } + + private func historyFilter(for item: AgentInventoryItem) -> TrafficFilter { + TrafficFilter(bundleIDs: item.trafficIdentities, includeAttributedChildren: true) + } + + private func loadInstalledApps() async { + workspaceStore.refresh() + let apps = await Task.detached(priority: .utility) { InstalledApps.all() }.value + guard !Task.isCancelled else { return } + installedApps = apps + lastInstalledAppScan = Date() + } + private func load() async { + if Date().timeIntervalSince(lastInstalledAppScan) >= 300 { + await loadInstalledApps() + } + guard !Task.isCancelled else { return } let (g, from, to) = (window.granularity, Date().addingTimeInterval(-window.interval), Date()) let scope = focus.scope - let result = try? await monitor.read { db -> ([BreakdownRow], [AlertRecord], [String: AgentPolicy], [HTTPExchange]) in + let result = try? await monitor.read { + db -> ([BreakdownRow], [AgentOpenAlertCount], [String: AgentPolicy], [HTTPExchange], Set) in (try AgentsModel.rows(from: db, granularity: g, from: from, to: to, focus: scope), - try db.alerts(limit: 2000, focus: scope).filter { $0.timestamp >= from }, try db.loadPolicies(), - try db.exchanges(since: from, limit: 5000, focus: scope)) + try db.openAlertCounts(since: from, focus: scope), + try db.loadPolicies(), + try db.exchanges(since: from, limit: 5000, focus: scope), + try db.inspectedAgentIDs(since: from, focus: scope)) } - guard let (rows, alerts, loadedPolicies, exchanges) = result else { return } + guard let (rows, loadedAlertCounts, loadedPolicies, exchanges, loadedInspectedAgents) = result else { return } + openAlertCounts = loadedAlertCounts policies = loadedPolicies - let activity = ToolActivityBuilder.activities(exchanges) + trafficAgents = AgentsModel.build(rows: rows, alerts: []) + inspectedAgents = loadedInspectedAgents + lastInspectionInventoryState = inspectionInventoryState + let snapshot = inventorySnapshot() + + var canonicalByIdentity: [String: String] = [:] + for item in snapshot { + for identity in item.identities { + let key = AgentCatalog.identityComparisonKey(identity) + if canonicalByIdentity[key] == nil { canonicalByIdentity[key] = item.id } + } + } + let canonicalExchanges = exchanges.map { exchange -> HTTPExchange in + var exchange = exchange + if let agent = exchange.agent, + let canonical = canonicalByIdentity[AgentCatalog.identityComparisonKey(agent)] { + exchange.agent = canonical + } + return exchange + } + let activity = ToolActivityBuilder.activities(canonicalExchanges) toolActivity = activity toolUsage = ToolUsage.build(activity) - let configured = Dictionary(grouping: rows.filter { !$0.mcpServer.isEmpty && !$0.parentAgent.isEmpty }) { $0.parentAgent } - .mapValues { Array(Set($0.map(\.mcpServer))) } - mcpServers = ToolActivityBuilder.servers(exchanges, activities: activity, configured: configured) - profiles = ToolActivityBuilder.profiles(exchanges, activities: activity) - inspectedAgents = Set(exchanges.compactMap(\.agent)) - let built = AgentsModel.build(rows: rows, alerts: alerts) - agents = built - let ids = Set(built.map(\.bundleID)) - agentAlerts = alerts.filter { AnomalyEngine.Kind.agentKinds.contains($0.kind) || ids.contains($0.bundleID) && $0.severity >= 2 }.count - if selection == nil || !ids.contains(selection!) { selection = built.first?.id } + let configured = Dictionary( + grouping: rows.filter { !$0.mcpServer.isEmpty && !$0.parentAgent.isEmpty }, + by: { canonicalByIdentity[AgentCatalog.identityComparisonKey($0.parentAgent)] ?? $0.parentAgent } + ).mapValues { Array(Set($0.map(\.mcpServer))) } + mcpServers = ToolActivityBuilder.servers(canonicalExchanges, activities: activity, configured: configured) + profiles = ToolActivityBuilder.profiles(canonicalExchanges, activities: activity) + inspectedAgents.formUnion(loadedInspectedAgents.compactMap { + canonicalByIdentity[AgentCatalog.identityComparisonKey($0)] + }) + publishInventory(snapshot) loaded = true } } +private struct AgentHealthBadge: View { + var state: AgentHealthState + + var body: some View { + Label(state.title, systemImage: icon) + .font(.caption.weight(.medium)) + .foregroundStyle(tint) + } + + private var icon: String { + switch state { + case .healthy: return "checkmark.circle.fill" + case .needsSetup: return "wrench.and.screwdriver.fill" + case .limitedVisibility: return "eye.slash.fill" + case .attention: return "exclamationmark.triangle.fill" + case .inactive: return "pause.circle" + case .notObserved: return "questionmark.circle" + } + } + + private var tint: Color { + switch state { + case .healthy: return FL.good + case .attention: return TrafficColors.anomaly + case .needsSetup, .limitedVisibility: return FL.warning + case .inactive, .notObserved: return .secondary + } + } +} + +private struct AgentInventoryStatusDetail: View { + var item: AgentInventoryItem + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + AppIconView(path: item.appPath, size: 32) + VStack(alignment: .leading, spacing: 2) { + Text(item.name).font(.title3.weight(.semibold)) + AgentHealthBadge(state: item.health) + } + } + Text(L("No traffic detail is available for this agent in the selected window.")) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 7) { + ForEach(item.reasons, id: \.self) { reason in + Label(reason, systemImage: "info.circle").fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 0) + } + .padding(Spacing.m) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .flCard() + } +} + /// The one-click way to keep a supported agent routed through Flowlight for good. /// /// When inspection is on but a known, editable agent (today: Claude Code) is reaching its model provider directly — @@ -530,12 +838,21 @@ struct AgentDetail: View { var inspected = false var workspaces: [AgentWorkspace] = [] var policy: AgentPolicy + var historyFilter: TrafficFilter + var requestedPolicySection: AgentPolicySection? + var requestedGuardrailID: UUID? = nil + var consumeRequestedPolicySection: () -> Void = {} var save: (AgentPolicy) -> Void @State private var tab = Tab.fromLaunchArgument + @State private var focusedGuardrailID: UUID? enum Tab: Hashable { case destinations, calls, tools, servers, guardrails + static func policyLanding(_ section: AgentPolicySection) -> Tab { + section == .guardrails ? .guardrails : .destinations + } + /// `-FLAgentTab tools`, for the screenshots. The default is what someone actually opens on. static var fromLaunchArgument: Tab { switch UserDefaults.standard.string(forKey: "FLAgentTab") { @@ -604,33 +921,31 @@ struct AgentDetail: View { Divider().padding(.vertical, 2) AllowlistEditor(agentName: agent.name, policy: policy, save: save) } - .frame(width: 280, alignment: .leading) + .frame(width: 220, alignment: .leading) Divider() VStack(alignment: .leading, spacing: 6) { HStack { - do { - Picker(L("Show"), selection: $tab) { - Text(L("Destinations (%lld)", agent.otherDestinations.count)).tag(Tab.destinations) - Text(activity.isEmpty ? L("Tool calls") : L("Tool calls (%lld)", activity.count)).tag(Tab.calls) - Text(toolCount > 0 ? L("Tools (%lld)", toolCount) : L("Tools")).tag(Tab.tools) - let mcpCount = servers.count + unusedServers.count - Text(mcpCount > 0 ? L("MCP servers (%lld)", mcpCount) : L("MCP servers")).tag(Tab.servers) - Text(L("Guardrails")).tag(Tab.guardrails) - } - .pickerStyle(.segmented).labelsHidden().fixedSize().controlSize(.small) + ViewThatFits(in: .horizontal) { + segmentedDetailPicker + .fixedSize() + compactDetailPicker + .frame(maxWidth: 180, alignment: .leading) } Spacer() Button(L("Open in History")) { - nav.showReport(filter: TrafficFilter(bundleID: agent.bundleID), granularity: window.granularity) + nav.showReport(filter: historyFilter, granularity: window.granularity) } .controlSize(.small) } switch tab { case .destinations: if agent.otherDestinations.isEmpty { - Text(L("Only AI providers. Nothing else was contacted.")).font(.caption).foregroundStyle(.secondary) + Text(agent.providers.isEmpty + ? L("No traffic was observed in the selected window.") + : L("Only AI providers. Nothing else was contacted.")) + .font(.caption).foregroundStyle(.secondary) } ScrollView { VStack(spacing: 3) { @@ -718,7 +1033,8 @@ struct AgentDetail: View { case .guardrails: GuardrailsPane(store: monitor.guardrails, agentKey: agent.bundleID, agentName: agent.name, declared: profile?.tools.map(\.tool) ?? [], - servers: Array(Set(servers.map(\.name) + unusedServers.map(\.server.name))).sorted()) + servers: Array(Set(servers.map(\.name) + unusedServers.map(\.server.name))).sorted(), + focus: focusedGuardrailID) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -739,6 +1055,37 @@ struct AgentDetail: View { } .lineLimit(1) } + .onChange(of: requestedPolicySection, initial: true) { _, section in + guard let section else { return } + // The allowlist is always visible in the left column; returning the right column to Destinations keeps + // it in the normal overview instead of preserving a Guardrails tab from a previous deep link. + focusedGuardrailID = section == .guardrails ? requestedGuardrailID : nil + tab = Tab.policyLanding(section) + consumeRequestedPolicySection() + } + } + + private var segmentedDetailPicker: some View { + Picker(L("Show"), selection: $tab) { detailTabOptions } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + } + + private var compactDetailPicker: some View { + Picker(L("Show"), selection: $tab) { detailTabOptions } + .pickerStyle(.menu) + .labelsHidden() + .controlSize(.small) + } + + @ViewBuilder private var detailTabOptions: some View { + Text(L("Destinations (%lld)", agent.otherDestinations.count)).tag(Tab.destinations) + Text(activity.isEmpty ? L("Tool calls") : L("Tool calls (%lld)", activity.count)).tag(Tab.calls) + Text(toolCount > 0 ? L("Tools (%lld)", toolCount) : L("Tools")).tag(Tab.tools) + let mcpCount = servers.count + unusedServers.count + Text(mcpCount > 0 ? L("MCP servers (%lld)", mcpCount) : L("MCP servers")).tag(Tab.servers) + Text(L("Guardrails")).tag(Tab.guardrails) } /// Tools declared to the model, plus everything configured on disk. diff --git a/Flowlight/UI/AlertsView.swift b/Flowlight/UI/AlertsView.swift index 4c6fd742..f521ab8f 100644 --- a/Flowlight/UI/AlertsView.swift +++ b/Flowlight/UI/AlertsView.swift @@ -1,5 +1,11 @@ import SwiftUI +enum AlertRuleSelection { + static func retained(_ rule: String, in alerts: [AlertRecord]) -> String { + rule.isEmpty || alerts.contains(where: { $0.kind == rule }) ? rule : "" + } +} + struct AlertsView: View { @EnvironmentObject var monitor: TrafficMonitor @EnvironmentObject var nav: AppNavigation @@ -7,13 +13,23 @@ struct AlertsView: View { @State private var alerts: [AlertRecord] = [] @State private var selection = Set() @State private var sortOrder = [KeyPathComparator(\AlertRecord.timestamp, order: .reverse)] - @State private var showAcknowledged = false + @State private var triageFilter: AlertTriageFilter = .open @State private var rule: String = "" + @State private var noteAlert: AlertRecord? + @State private var noteDraft = "" private var rules: [String] { Array(Set(alerts.map(\.kind))).sorted() } + private struct LoadKey: Equatable { + var version: Int + var newAlerts: Int + var focus: FocusScope + var tab: AlertsTab + var filter: AlertTriageFilter + } + private var visible: [AlertRecord] { - alerts.filter { (showAcknowledged || !$0.acknowledged) && (rule.isEmpty || $0.kind == rule) }.sorted(using: sortOrder) + alerts.filter { rule.isEmpty || $0.kind == rule }.sorted(using: sortOrder) } var body: some View { @@ -32,10 +48,9 @@ struct AlertsView: View { if nav.alertsTab == .detection { DetectionSettingsView() } else if visible.isEmpty { - ContentUnavailableView(alerts.isEmpty ? L("No alerts") : L("Nothing to review"), systemImage: "checkmark.shield", - description: Text(alerts.isEmpty - ? L("Each app has a learning period (24 h by default) before first-contact and spike rules fire.") - : L("All matching alerts have been acknowledged."))) + ContentUnavailableView(alerts.isEmpty ? L("No matching alerts") : L("Nothing matches this rule"), + systemImage: "checkmark.shield", + description: Text(emptyDescription)) } else { table } @@ -43,17 +58,13 @@ struct AlertsView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .navigationTitle(L("Alerts")) - // The sidebar badge counts what is unread; this says how much there is to read at all, which is the - // difference between "quiet" and "already dealt with". - .navigationSubtitle(nav.alertsTab == .detection ? L("Detection") - : alerts.isEmpty ? "" - : monitor.unacknowledgedAlerts == 0 ? L("%lld alerts, all acknowledged", alerts.count) - : L("%lld to review of %lld", monitor.unacknowledgedAlerts, alerts.count)) + .navigationSubtitle(nav.alertsTab == .detection ? L("Detection") : alertSubtitle) .toolbar { toolbar } - .task(id: monitor.dataVersion) { await load() } - .task(id: monitor.unacknowledgedAlerts) { await load() } - .task(id: focus.scope) { await load() } - .task(id: nav.alertsTab) { await load() } + .task(id: LoadKey(version: monitor.dataVersion, newAlerts: monitor.unacknowledgedAlerts, + focus: focus.scope, tab: nav.alertsTab, filter: triageFilter)) { + await load() + } + .sheet(item: $noteAlert) { alert in noteEditor(alert) } } private var table: some View { @@ -61,7 +72,7 @@ struct AlertsView: View { TableColumn("", value: \.severity) { a in Image(systemName: a.severity >= 3 ? "exclamationmark.octagon.fill" : a.severity == 2 ? "exclamationmark.triangle.fill" : "info.circle") .foregroundStyle(a.severity >= 3 ? FL.critical : a.severity == 2 ? FL.warning : .secondary) - .opacity(a.acknowledged ? 0.4 : 1) + .opacity(a.state == .new ? 1 : 0.55) .accessibilityLabel(a.severity >= 3 ? L("Critical") : a.severity == 2 ? L("Warning") : L("Info")) } .width(24) @@ -71,28 +82,40 @@ struct AlertsView: View { } .width(min: 130, ideal: 150) TableColumn(L("App"), value: \.appName) { a in - Text(a.appName).help(a.bundleID).foregroundStyle(a.acknowledged ? .secondary : .primary) + Text(a.appName).help(a.bundleID).foregroundStyle(rowStyle(a)) } .width(min: 100, ideal: 150) + TableColumn(L("State")) { a in + Text(a.state.title).foregroundStyle(rowStyle(a)) + } + .width(min: 95, ideal: 115) + TableColumn(L("Disposition")) { a in + Text(a.disposition.title).foregroundStyle(a.disposition == .unset ? .secondary : rowStyle(a)) + } + .width(min: 110, ideal: 145) // The stored kind is English — it is a database value as much as a label — so the column shows the // same sentence in the chosen language rather than the row as it was written. TableColumn(L("Rule"), value: \.kind) { a in - Text(AnomalyEngine.Kind.localizedName(a.kind)) - .foregroundStyle(a.acknowledged ? .secondary : .primary) + Text(AnomalyEngine.Kind.localizedName(a.kind)).foregroundStyle(rowStyle(a)) } .width(min: 140, ideal: 190) TableColumn(L("Detail"), value: \.detail) { a in - Text(a.detail).lineLimit(2).help(a.detail).foregroundStyle(a.acknowledged ? .secondary : .primary) + HStack(spacing: 6) { + Text(a.detail).lineLimit(2).help(a.detail).foregroundStyle(rowStyle(a)) + if !a.note.isEmpty { + Image(systemName: "note.text").foregroundStyle(.secondary).help(a.note) + } + } } } .contextMenu(forSelectionType: AlertRecord.ID.self) { ids in - // The way out of a connection Flowlight refused: the agent will try again, and next time it gets through. + triageActions(ids) + Divider() + // Allow actions remain policy changes, separate from triage metadata and its lifecycle. if let a = alert(ids.first), !a.allowPattern.isEmpty { Button(L("Allow %@ for %@ from Now On", a.allowPattern, a.appName)) { monitor.allowFromNowOn(pattern: a.allowPattern, agentID: a.bundleID) } - // Every refusal carries its way out, and each way out is itself a rule with an expiry — so the - // Rules screen can still answer "why is this getting through?" tomorrow morning. Menu(L("Allow %@ for %@…", a.allowPattern, a.appName)) { Button(L("Just once")) { monitor.rules.save(RuleStore.allow(app: a.bundleID, destination: a.allowPattern, @@ -114,8 +137,8 @@ struct AlertsView: View { } Divider() } - Button(L("Show Traffic in History")) { if let a = alert(ids.first) { openReport(a) } }.disabled(ids.count != 1) - Button(L("Acknowledge")) { monitor.acknowledgeAlerts(ids: Array(ids)) } + Button(L("Show Traffic in History")) { if let a = alert(ids.first) { openReport(a) } } + .disabled(ids.count != 1) Button(L("Copy Detail")) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(ids.compactMap { alert($0)?.detail }.joined(separator: "\n"), forType: .string) @@ -127,53 +150,137 @@ struct AlertsView: View { @ToolbarContentBuilder private var toolbar: some ToolbarContent { - // Two menus rather than four controls of four different shapes. The filter gathers everything that - // narrows the list; acknowledging is one verb with two objects, so it reads better as one button. if nav.alertsTab == .alerts { ToolbarItemGroup { Menu { + Picker(L("Status"), selection: $triageFilter) { + ForEach(AlertTriageFilter.allCases, id: \.self) { Text($0.title).tag($0) } + } + .pickerStyle(.inline) + Divider() Picker(L("Rule"), selection: $rule) { Text(L("All rules")).tag("") ForEach(rules, id: \.self) { Text(AnomalyEngine.Kind.localizedName($0)).tag($0) } } .pickerStyle(.inline) - Divider() - Toggle(L("Show acknowledged"), isOn: $showAcknowledged) } label: { Label(filterLabel, systemImage: isFiltered ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") } - .labelStyle(.titleAndIcon) // icon-only reads as two mystery buttons in the corner - .disabled(alerts.isEmpty) + .labelStyle(.titleAndIcon) .help(L("Filter which alerts are listed")) Menu { - Button(L("Selected (%lld)", selection.count)) { monitor.acknowledgeAlerts(ids: Array(selection)) } - .disabled(selection.isEmpty) - Button(L("All unacknowledged (%lld)", monitor.unacknowledgedAlerts)) { monitor.acknowledgeAlerts(ids: nil) } - .disabled(monitor.unacknowledgedAlerts == 0) + triageActions(selection) + Divider() + Button(L("Acknowledge all new (%lld)", monitor.unacknowledgedAlerts)) { + let scope = focus.scope + Task { await monitor.acknowledgeAlerts(ids: nil, focus: scope) } + } + .disabled(monitor.unacknowledgedAlerts == 0) } label: { - Label(L("Acknowledge"), systemImage: "checkmark.circle") + Label(L("Triage"), systemImage: "checkmark.circle") } .labelStyle(.titleAndIcon) - .disabled(alerts.isEmpty) - .help(L("Mark alerts as seen")) + .help(L("Update the selected alerts")) + } + } + } + + @ViewBuilder + private func triageActions(_ ids: Set) -> some View { + let selected = ids.compactMap { alert($0) } + Menu(L("Set State")) { + ForEach(AlertTriageState.allCases, id: \.self) { state in + Button(state.title) { + let selectedIDs = Array(ids) + Task { await monitor.setAlertTriageState(state, ids: selectedIDs) } + } + .disabled(selected.isEmpty || selected.contains { !$0.state.canTransition(to: state) }) + } + } + .disabled(selected.isEmpty) + Menu(L("Set Disposition")) { + ForEach(AlertDisposition.allCases, id: \.self) { disposition in + Button(disposition.title) { + let selectedIDs = Array(ids) + Task { await monitor.setAlertDisposition(disposition, ids: selectedIDs) } + } + } + } + .disabled(selected.isEmpty) + Button(L("Edit Note…")) { + guard let alert = selected.first else { return } + noteDraft = alert.note + noteAlert = alert + } + .disabled(selected.count != 1) + } + + private func noteEditor(_ alert: AlertRecord) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(L("Triage Note")).font(.headline) + Text(alert.appName).foregroundStyle(.secondary) + TextEditor(text: $noteDraft) + .font(.body) + .frame(minHeight: 150) + .onChange(of: noteDraft) { _, value in + if value.count > AlertRecord.maximumNoteLength { + noteDraft = String(value.prefix(AlertRecord.maximumNoteLength)) + } + } + Text("\(noteDraft.count) / \(AlertRecord.maximumNoteLength)") + .font(.caption).foregroundStyle(.secondary).monospacedDigit() + HStack { + Spacer() + Button(L("Cancel")) { noteAlert = nil } + Button(L("Save")) { + let note = noteDraft + Task { + if await monitor.setAlertNote(note, id: alert.id) { + noteAlert = nil + } + } + } + .keyboardShortcut(.defaultAction) } } + .padding(20) + .frame(width: 440, height: 300) } - private var isFiltered: Bool { !rule.isEmpty || showAcknowledged } + private var isFiltered: Bool { !rule.isEmpty || triageFilter != .open } - /// Names what the filter is doing, so the toolbar says it without a second control. private var filterLabel: String { - switch (rule.isEmpty, showAcknowledged) { - case (true, false): return L("All rules") - case (true, true): return L("All rules, including seen") - case (false, false): return AnomalyEngine.Kind.localizedName(rule) - case (false, true): return L("%@, including seen", AnomalyEngine.Kind.localizedName(rule)) + rule.isEmpty ? triageFilter.title : L("%@ · %@", triageFilter.title, AnomalyEngine.Kind.localizedName(rule)) + } + + private var alertSubtitle: String { + guard !visible.isEmpty else { return "" } + switch triageFilter { + case .open: + let newCount = visible.filter { $0.state == .new }.count + return newCount == 0 ? L("%lld open, no new", visible.count) + : L("%lld new of %lld open", newCount, visible.count) + case .investigating: return L("%lld investigating", visible.count) + case .resolved: return L("%lld resolved", visible.count) + case .dismissed: return L("%lld dismissed", visible.count) } } + private var emptyDescription: String { + switch triageFilter { + case .open: return L("No open alerts match the current Focus scope.") + case .investigating: return L("No alerts are being investigated in the current Focus scope.") + case .resolved: return L("No resolved alerts match the current Focus scope.") + case .dismissed: return L("No dismissed alerts match the current Focus scope.") + } + } + + private func rowStyle(_ alert: AlertRecord) -> Color { + alert.state.isClosed ? .secondary : .primary + } + private func alert(_ id: AlertRecord.ID?) -> AlertRecord? { alerts.first { $0.id == id } } @@ -187,6 +294,46 @@ struct AlertsView: View { private func load() async { guard nav.alertsTab == .alerts else { return } let scope = focus.scope - alerts = (try? await monitor.read { try $0.alerts(focus: scope) }) ?? [] + let filter = triageFilter + let loaded = (try? await monitor.read { try $0.alerts(focus: scope, triageFilter: filter) }) ?? [] + guard !Task.isCancelled, nav.alertsTab == .alerts, + focus.scope == scope, triageFilter == filter else { return } + rule = AlertRuleSelection.retained(rule, in: loaded) + alerts = loaded + selection.formIntersection(alerts.map(\.id)) + } +} + +private extension AlertTriageState { + var title: String { + switch self { + case .new: return L("New") + case .acknowledged: return L("Acknowledged") + case .investigating: return L("Investigating") + case .resolved: return L("Resolved") + case .dismissed: return L("Dismissed") + } + } +} + +private extension AlertDisposition { + var title: String { + switch self { + case .unset: return L("Unset") + case .truePositive: return L("True positive") + case .expectedBenign: return L("Expected / benign") + case .falsePositive: return L("False positive") + } + } +} + +private extension AlertTriageFilter { + var title: String { + switch self { + case .open: return L("Open") + case .investigating: return L("Investigating") + case .resolved: return L("Resolved") + case .dismissed: return L("Dismissed") + } } } diff --git a/Flowlight/UI/CaptureView.swift b/Flowlight/UI/CaptureView.swift index 815dbd01..d6309a7c 100644 --- a/Flowlight/UI/CaptureView.swift +++ b/Flowlight/UI/CaptureView.swift @@ -1,177 +1,82 @@ import SwiftUI -/// Pure recovery-state decisions keep the Sources checklist honest and easy to exercise without constructing views. -enum SourceSetupPolicy { - enum CoverageHealth: Equatable { case empty, good, warning } - - static func coverageHealth(named: Double, hasTraffic: Bool) -> CoverageHealth { - guard hasTraffic else { return .empty } - return named > 0.8 ? .good : .warning - } - - static func collectorConfigured(mode: CaptureMode, extensionState: ExtensionManager.State, - extensionFellBack: Bool) -> Bool { - if mode == .nettop { return true } - return extensionState == .enabled && !extensionFellBack - } - - static func setupComplete(mode: CaptureMode, extensionState: ExtensionManager.State, - extensionFellBack: Bool, isReceiving: Bool) -> Bool { - collectorConfigured(mode: mode, extensionState: extensionState, - extensionFellBack: extensionFellBack) && isReceiving - } -} - -/// Collector setup, device channels and coverage belong to one operational destination. The compact health strip -/// answers "is Flowlight working?" before someone has to know which of the three tabs contains the recovery action. +/// Collector setup, device channels and coverage form one operational document. A requested section is a scroll +/// target, not a visibility filter: links and restored windows establish context while the full workflow remains. struct SourcesView: View { - @Binding var selection: SourcesTab @EnvironmentObject var monitor: TrafficMonitor - @EnvironmentObject var extensionManager: ExtensionManager + @Binding var selection: SourcesTab + var requestID: UUID + @AccessibilityFocusState private var accessibilityFocus: SourcesTab? + @State private var highlightedSection: SourcesTab? + @State private var focusGeneration = UUID() var body: some View { - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: 10) { - Picker(L("Sources"), selection: $selection) { - ForEach(SourcesTab.allCases) { tab in Text(tab.title).tag(tab) } - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(maxWidth: 420) - - HStack(spacing: 10) { - healthCard(.collector, icon: "antenna.radiowaves.left.and.right", - title: L("Collector"), detail: collectorDetail, - tint: monitor.isReceiving ? FL.good : FL.warning) - healthCard(.coverage, icon: "circle.dashed.inset.filled", - title: L("Coverage"), detail: coverageDetail, - tint: coverageTint) - healthCard(.devices, icon: "dot.radiowaves.left.and.right", - title: L("Devices"), detail: deviceDetail, - tint: monitor.devices.isWatchingAnything ? FL.good : .secondary) - } - - if !sourceSetupComplete { sourceRecoveryChecklist } - } - .padding(.horizontal, Measure.gutter) - .padding(.vertical, 12) - .background(.bar) - .overlay(alignment: .bottom) { Divider() } - - Group { - switch selection { - case .collector: CaptureView() - case .devices: DevicesView() - case .coverage: CoverageView() + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 32) { + sourcesSection(.collector, + icon: "antenna.radiowaves.left.and.right", + detail: L("Choose the traffic source and verify that data is arriving.")) { + CaptureView(embedded: true, liveState: monitor.liveState) + } + sourcesSection(.devices, + icon: "dot.radiowaves.left.and.right", + detail: L("Choose which local device channels Flowlight watches.")) { + DevicesView(embedded: true) + } + sourcesSection(.coverage, + icon: "circle.dashed.inset.filled", + detail: L("Check what Flowlight can name, attribute, and inspect.")) { + CoverageView(embedded: true) + } } + .padding(Measure.gutter) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { focus(selection, in: proxy) } + .onChange(of: selection) { _, section in focus(section, in: proxy) } + .onChange(of: requestID) { focus(selection, in: proxy) } } .navigationTitle(L("Sources")) - .navigationSubtitle(selection.title) - } - - private var collectorDetail: String { - if monitor.extensionFellBack { - #if APP_STORE - // The Store product has no sampler fallback; its fail-closed source is unavailable, not nettop. - return L("Nothing is arriving") - #else - return L("nettop sampler") - #endif - } - return monitor.isReceiving ? L("Data is arriving") : L("Nothing is arriving") - } - - private var coverageHealth: SourceSetupPolicy.CoverageHealth { - SourceSetupPolicy.coverageHealth(named: monitor.coverage.named, - hasTraffic: monitor.coverage.hasTraffic) - } - - private var coverageDetail: String { - guard coverageHealth != .empty else { return L("Nothing recorded in the last hour") } - return "\(monitor.coverage.named.formatted(.percent.precision(.fractionLength(0)))) \(L("Named"))" - } - - private var coverageTint: Color { - switch coverageHealth { - case .empty: return .secondary - case .good: return FL.good - case .warning: return FL.warning - } } - private var deviceDetail: String { - guard monitor.devices.isWatchingAnything else { return L("Not watching") } - var channels: [String] = [] - if monitor.devices.watchingBluetooth { channels.append(L("Bluetooth")) } - if monitor.devices.watchingUSB { channels.append(L("USB")) } - if monitor.devices.watchingCamera { channels.append(L("Camera")) } - return channels.joined(separator: " · ") - } - - private var collectorConfigured: Bool { - SourceSetupPolicy.collectorConfigured(mode: monitor.mode, extensionState: extensionManager.state, - extensionFellBack: monitor.extensionFellBack) - } - - private var sourceSetupComplete: Bool { - SourceSetupPolicy.setupComplete(mode: monitor.mode, extensionState: extensionManager.state, - extensionFellBack: monitor.extensionFellBack, - isReceiving: monitor.isReceiving) - } - - /// A short recovery path stays beside the source status. It reports completed steps instead of presenting one - /// generic error, and the action opens the one place where every unfinished step can be repaired. - private var sourceRecoveryChecklist: some View { - HStack(alignment: .top, spacing: 14) { - VStack(alignment: .leading, spacing: 7) { - Text(monitor.isReceiving ? L("Collector") : L("Nothing is arriving")) - .font(.caption.weight(.semibold)) - checklistRow(monitor.mode == .networkExtension ? L("Network Extension") : L("nettop sampler"), - complete: collectorConfigured) - checklistRow(L("Data is arriving"), complete: monitor.isReceiving) - } - Spacer(minLength: 8) - Button { selection = .collector } label: { - Label(L("Collector"), systemImage: "chevron.right") - .labelStyle(.titleAndIcon) - } - .buttonStyle(.bordered) - .controlSize(.small) - } - .padding(10) - .background(FL.warning.opacity(0.10), in: RoundedRectangle(cornerRadius: 9)) - .overlay(RoundedRectangle(cornerRadius: 9).stroke(FL.warning.opacity(0.25))) - } - - private func checklistRow(_ title: String, complete: Bool) -> some View { - Label(title, systemImage: complete ? "checkmark.circle.fill" : "circle") - .font(.caption) - .foregroundStyle(complete ? FL.good : .secondary) - } - - private func healthCard(_ tab: SourcesTab, icon: String, title: String, - detail: String, tint: Color) -> some View { - Button { selection = tab } label: { - HStack(alignment: .top, spacing: 9) { + private func sourcesSection(_ section: SourcesTab, icon: String, detail: String, + @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 10) { Image(systemName: icon) - .foregroundStyle(tint) - .frame(width: 22, height: 22) - VStack(alignment: .leading, spacing: 2) { - Text(title).font(.caption.weight(.semibold)) - Text(detail).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + .font(.title3) + .foregroundStyle(FL.accent) + .frame(width: 26) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(section.title).font(.title2.weight(.semibold)) + Text(detail).font(.callout).foregroundStyle(.secondary).prose() } - Spacer(minLength: 0) } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(selection == tab ? FL.accent.opacity(0.10) : Color.secondary.opacity(0.07), + .padding(8) + .background(highlightedSection == section ? FL.accent.opacity(0.12) : .clear, in: RoundedRectangle(cornerRadius: 9)) - .contentShape(Rectangle()) + .accessibilityFocused($accessibilityFocus, equals: section) + + content() + } + .id(section.anchorID) + } + + @MainActor + private func focus(_ section: SourcesTab, in proxy: ScrollViewProxy) { + selection = section + proxy.scrollTo(section.anchorID, anchor: .top) + accessibilityFocus = section + highlightedSection = section + + let generation = UUID() + focusGeneration = generation + Task { @MainActor in + try? await Task.sleep(for: .seconds(1.4)) + guard focusGeneration == generation else { return } + highlightedSection = nil } - .buttonStyle(.plain) - .accessibilityAddTraits(selection == tab ? .isSelected : []) } } @@ -182,6 +87,8 @@ struct SourcesView: View { /// explanations are all still here and all still true, but they are not what someone opens this panel for, so they /// sit behind a summary row instead of in front of the controls. struct CaptureView: View { + var embedded = false + @ObservedObject var liveState: TrafficLiveState @EnvironmentObject var monitor: TrafficMonitor @EnvironmentObject var extensionManager: ExtensionManager @State private var showExtensionSetup = false @@ -191,34 +98,39 @@ struct CaptureView: View { /// source that was chosen, and arriving from the one that stepped in when that source gave up. private var statusColor: Color { if monitor.extensionFellBack { return .yellow } - return monitor.isReceiving ? FL.good : FL.warning + return liveState.isReceiving ? FL.good : FL.warning } private var extensionDetailsVisible: Bool { monitor.mode == .networkExtension || showExtensionSetup } - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 18) { - statusCard - CaptureWarningBanner() - if !otherFilters.isEmpty { OtherFiltersNotice(filters: otherFilters) } - sourceSection - filterSection + @ViewBuilder var body: some View { + if embedded { + content #if !APP_STORE - if monitor.mode == .nettop { HostnameSection() } + // Which other content filters exist decides whether ours can work at all, so it's read on arrival. + .task { otherFilters = SystemExtensionScan.competingFilters(in: await SystemExtensionScan.read()) } + #endif + } else { + ScrollView { content.padding(Measure.gutter) } + #if !APP_STORE + .task { otherFilters = SystemExtensionScan.competingFilters(in: await SystemExtensionScan.read()) } #endif - } - // A settings panel is read, not scanned, and a line of prose stretched across a maximised window is - // unreadable. The column stops at a comfortable measure and stays against the sidebar rather than - // floating in the middle of the window. - .padding(Measure.gutter) } - #if !APP_STORE - // Which other content filters exist decides whether ours can work at all, so it's read when the screen opens. - .task { otherFilters = SystemExtensionScan.competingFilters(in: await SystemExtensionScan.read()) } - #endif + } + + private var content: some View { + LazyVStack(alignment: .leading, spacing: 18) { + statusCard + CaptureWarningBanner() + if !otherFilters.isEmpty { OtherFiltersNotice(filters: otherFilters) } + sourceSection + filterSection + #if !APP_STORE + if monitor.mode == .nettop { HostnameSection() } + #endif + } } // MARK: Source @@ -300,7 +212,7 @@ struct CaptureView: View { // Green means "data is arriving from the source you chose". After a fallback the sampler is genuinely // receiving, so a plain green dot beside a line explaining that the extension failed would contradict // itself. Yellow is that third state: working, but not as asked. - LiveDot(color: statusColor, active: monitor.isReceiving, size: 10) + LiveDot(color: statusColor, active: liveState.isReceiving, size: 10) .frame(width: 26, height: 26) .background(statusColor.opacity(0.14), in: RoundedRectangle(cornerRadius: 7)) VStack(alignment: .leading, spacing: 3) { @@ -322,7 +234,7 @@ struct CaptureView: View { return L("Arriving from nettop, not the extension") #endif } - return monitor.isReceiving ? L("Data is arriving") : L("Nothing is arriving") + return liveState.isReceiving ? L("Data is arriving") : L("Nothing is arriving") } // MARK: Network Extension diff --git a/Flowlight/UI/ContentView.swift b/Flowlight/UI/ContentView.swift index 39685c3c..04021330 100644 --- a/Flowlight/UI/ContentView.swift +++ b/Flowlight/UI/ContentView.swift @@ -56,30 +56,13 @@ struct ContentView: View { } #endif FocusBar() - Button { nav.showSources(.collector) } label: { - HStack(alignment: .top, spacing: 8) { - LiveDot(color: monitor.isReceiving ? FL.good : FL.warning, active: monitor.isReceiving) - .padding(.top, 4) - VStack(alignment: .leading, spacing: 2) { - Text(DemoData.isEnabled ? L("Demo data") - : monitor.mode == .networkExtension ? L("Network Extension") : L("nettop sampler")) - .font(.caption.bold()) - Text(monitor.status).font(.caption2).foregroundStyle(.secondary).lineLimit(2) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .padding(10) - .help(L("Capture source status — click to configure")) - .accessibilityLabel(L("Capture status: %@", monitor.status)) + CaptureSourceStatus(liveState: monitor.liveState) } } } detail: { Group { switch nav.selection { - case .live: LiveView() + case .live: LiveView(liveState: monitor.liveState) case .agents: AgentsView() case .reports: ReportsView() case .visualize: VisualizeView() @@ -87,12 +70,12 @@ struct ContentView: View { case .rules: RulesView() case .inspect: #if APP_STORE - LiveView() + LiveView(liveState: monitor.liveState) #else InspectView() #endif case .ask: AskView() - case .sources: SourcesView(selection: $nav.sourcesTab) + case .sources: SourcesView(selection: $nav.sourcesTab, requestID: nav.sourcesRequestID) } } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -192,3 +175,33 @@ struct ContentView: View { .help(L("Review…")) } } + +/// The one-second capture indicator owns the live-state observation so it can update without rebuilding ContentView +/// and whichever full-screen destination is currently retained in its navigation hierarchy. +private struct CaptureSourceStatus: View { + @ObservedObject var liveState: TrafficLiveState + @EnvironmentObject var monitor: TrafficMonitor + @EnvironmentObject var nav: AppNavigation + + var body: some View { + Button { nav.showSources(.collector) } label: { + HStack(alignment: .top, spacing: 8) { + LiveDot(color: liveState.isReceiving ? FL.good : FL.warning, + active: liveState.isReceiving) + .padding(.top, 4) + VStack(alignment: .leading, spacing: 2) { + Text(DemoData.isEnabled ? L("Demo data") + : monitor.mode == .networkExtension ? L("Network Extension") : L("nettop sampler")) + .font(.caption.bold()) + Text(monitor.status).font(.caption2).foregroundStyle(.secondary).lineLimit(2) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(10) + .help(L("Capture source status — click to configure")) + .accessibilityLabel(L("Capture status: %@", monitor.status)) + } +} diff --git a/Flowlight/UI/CoverageView.swift b/Flowlight/UI/CoverageView.swift index 8900c456..aaecbe7b 100644 --- a/Flowlight/UI/CoverageView.swift +++ b/Flowlight/UI/CoverageView.swift @@ -6,44 +6,63 @@ import SwiftUI /// the question a monitoring tool usually leaves to its documentation — and which decides how much the other /// screens are worth. struct CoverageView: View { + var embedded = false @EnvironmentObject var monitor: TrafficMonitor - var body: some View { CoverageContent(inspection: monitor.inspection) } + var body: some View { CoverageContent(inspection: monitor.inspection, embedded: embedded) } } private struct CoverageContent: View { @ObservedObject var inspection: InspectionController + var embedded: Bool @EnvironmentObject var monitor: TrafficMonitor @EnvironmentObject var nav: AppNavigation @AppStorage("coverage.window") private var window: AgentWindow = .day @State private var rows: [AppCoverage] = [] @State private var loaded = false - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 18) { - summary - if rows.isEmpty && loaded { - ContentUnavailableView { - Label(L("Nothing recorded yet"), systemImage: "chart.bar.doc.horizontal") - } description: { - Text(L("Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment.")) + @ViewBuilder var body: some View { + if embedded { + VStack(alignment: .leading, spacing: 14) { + windowPicker.labelsHidden().frame(maxWidth: 320, alignment: .leading) + content + } + .task(id: LoadKey(window: window, version: monitor.dataVersion, inspecting: inspection.enabled)) { + await load() + } + } else { + ScrollView { content.padding(Measure.gutter) } + .toolbar { + ToolbarItem(placement: .primaryAction) { + windowPicker.labelsHidden() } - } else { - table } - } - .padding(Measure.gutter) + .task(id: LoadKey(window: window, version: monitor.dataVersion, inspecting: inspection.enabled)) { + await load() + } } - .toolbar { - ToolbarItem(placement: .primaryAction) { - Picker(L("Window"), selection: $window) { - ForEach(AgentWindow.allCases) { Text($0.title).tag($0) } + } + + private var windowPicker: some View { + Picker(L("Window"), selection: $window) { + ForEach(AgentWindow.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + } + + private var content: some View { + LazyVStack(alignment: .leading, spacing: 18) { + summary + if rows.isEmpty && loaded { + ContentUnavailableView { + Label(L("Nothing recorded yet"), systemImage: "chart.bar.doc.horizontal") + } description: { + Text(L("Coverage is worked out from traffic Flowlight has already recorded. Leave it running for a moment.")) } - .pickerStyle(.segmented).labelsHidden() + } else { + table } } - .task(id: LoadKey(window: window, version: monitor.dataVersion, inspecting: inspection.enabled)) { await load() } } private struct LoadKey: Equatable { var window: AgentWindow; var version: Int; var inspecting: Bool } diff --git a/Flowlight/UI/DesignTokens.swift b/Flowlight/UI/DesignTokens.swift index 8f50e78a..403812c7 100644 --- a/Flowlight/UI/DesignTokens.swift +++ b/Flowlight/UI/DesignTokens.swift @@ -23,7 +23,7 @@ enum FL { // Semantic. static let accent = Color(light: 0x4a3aa7, dark: 0x9085e9) static let received = Color(light: 0x2a78d6, dark: 0x4a93ea) - static let sent = Color(light: 0xd95926, dark: 0xe5723f) + static let sent = Color(light: 0xd95926, dark: 0xdf6c39) static let critical = Color(light: 0xc93b33, dark: 0xec6a62) static let warning = Color(light: 0xb86e00, dark: 0xe0a13a) static let good = Color(light: 0x1f9d6b, dark: 0x4fb98a) diff --git a/Flowlight/UI/DevicesView.swift b/Flowlight/UI/DevicesView.swift index 17936168..e9feaeb0 100644 --- a/Flowlight/UI/DevicesView.swift +++ b/Flowlight/UI/DevicesView.swift @@ -7,47 +7,77 @@ import SwiftUI /// that changed. It does not report bytes, because macOS does not account for these per app and a number invented /// here would be worse than no number. struct DevicesView: View { + var embedded = false @EnvironmentObject var monitor: TrafficMonitor var body: some View { - DevicesContent(store: monitor.devices) + DevicesContent(store: monitor.devices, embedded: embedded) } } private struct DevicesContent: View { @ObservedObject var store: DeviceStore + var embedded: Bool - var body: some View { - Group { - if store.isWatchingAnything { - content + @ViewBuilder var body: some View { + if embedded { + VStack(alignment: .leading, spacing: 12) { + embeddedControls + deviceContent + } + } else { + deviceContent.toolbar { toolbar } + } + } + + @ViewBuilder + private var deviceContent: some View { + if store.isWatchingAnything { + if embedded { + sections } else { - offer + ScrollView { sections.padding(Measure.gutter) } } + } else { + offer } - .toolbar { toolbar } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItemGroup { - if store.isWatchingAnything { - Button { store.refresh() } label: { Label(L("Refresh"), systemImage: "arrow.clockwise") } - .keyboardShortcut("r", modifiers: .command) - .help(L("Read the device list again now (⌘R)")) - } - Menu { - Toggle(L("Watch Bluetooth"), isOn: Binding(get: { store.watchingBluetooth }, - set: { store.watchingBluetooth = $0 })) - Toggle(L("Watch USB and external storage"), isOn: Binding(get: { store.watchingUSB }, - set: { store.watchingUSB = $0 })) - Toggle(L("Watch camera activity"), isOn: Binding(get: { store.watchingCamera }, - set: { store.watchingCamera = $0 })) - } label: { - Label(L("Channels"), systemImage: "switch.2") - } - .help(L("Choose which channels Flowlight watches")) + if store.isWatchingAnything { refreshButton } + channelMenu + } + } + + private var embeddedControls: some View { + HStack(spacing: 8) { + channelMenu + if store.isWatchingAnything { refreshButton } + Spacer(minLength: 0) } + .controlSize(.small) + } + + private var refreshButton: some View { + Button { store.refresh() } label: { Label(L("Refresh"), systemImage: "arrow.clockwise") } + .keyboardShortcut("r", modifiers: .command) + .help(L("Read the device list again now (⌘R)")) + } + + private var channelMenu: some View { + Menu { + Toggle(L("Watch Bluetooth"), isOn: Binding(get: { store.watchingBluetooth }, + set: { store.watchingBluetooth = $0 })) + Toggle(L("Watch USB and external storage"), isOn: Binding(get: { store.watchingUSB }, + set: { store.watchingUSB = $0 })) + Toggle(L("Watch camera activity"), isOn: Binding(get: { store.watchingCamera }, + set: { store.watchingCamera = $0 })) + } label: { + Label(L("Channels"), systemImage: "switch.2") + } + .help(L("Choose which channels Flowlight watches")) } /// Nothing is on yet. The offer says what each channel can and can't tell you before it is switched on, rather @@ -66,16 +96,13 @@ private struct DevicesContent: View { // MARK: The screen - private var content: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 18) { - accounting - if store.watchingBluetooth { bluetoothSection } - if store.watchingUSB { usbSection } - if store.watchingCamera { cameraSection } - if !store.events.isEmpty { historySection } - } - .padding(Measure.gutter) + private var sections: some View { + LazyVStack(alignment: .leading, spacing: 18) { + accounting + if store.watchingBluetooth { bluetoothSection } + if store.watchingUSB { usbSection } + if store.watchingCamera { cameraSection } + if !store.events.isEmpty { historySection } } } diff --git a/Flowlight/UI/GuardrailsPane.swift b/Flowlight/UI/GuardrailsPane.swift index e2918b38..fd7f6bf1 100644 --- a/Flowlight/UI/GuardrailsPane.swift +++ b/Flowlight/UI/GuardrailsPane.swift @@ -11,29 +11,37 @@ struct GuardrailsPane: View { let declared: [DeclaredTool] /// MCP servers it reached over the network. let servers: [String] + /// Optional policy deep-link target. The row keeps its normal controls and is scrolled into view on open. + var focus: UUID? = nil @State private var typed = "" private var mine: [Guardrail] { store.guardrails(for: agentKey) } var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - explanation - if !monitor.inspection.enabled { - Label(L("HTTPS inspection is off, so Flowlight can't read the tool list — nothing here is being carried out yet."), - systemImage: "exclamationmark.triangle") - .font(.caption).foregroundStyle(FL.warning).fixedSize(horizontal: false, vertical: true) - } + ScrollViewReader { scroll in + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + explanation + if !monitor.inspection.enabled { + Label(L("HTTPS inspection is off, so Flowlight can't read the tool list — nothing here is being carried out yet."), + systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(FL.warning).fixedSize(horizontal: false, vertical: true) + } - presets - if !declared.isEmpty { tools } - if !servers.isEmpty { serverList } - if !mine.isEmpty { existing } - custom - Divider().padding(.vertical, 2) - limits + presets + if !declared.isEmpty { tools } + if !servers.isEmpty { serverList } + if !mine.isEmpty { existing } + custom + Divider().padding(.vertical, 2) + limits + } + .padding(.trailing, 6) + } + .onChange(of: focus, initial: true) { _, target in + guard let target else { return } + DispatchQueue.main.async { scroll.scrollTo(target, anchor: .center) } } - .padding(.trailing, 6) } } @@ -123,6 +131,11 @@ struct GuardrailsPane: View { Button(role: .destructive) { store.delete(guardrail) } label: { Image(systemName: "trash") } .buttonStyle(.borderless) } + .padding(.vertical, 3) + .padding(.horizontal, 5) + .background(guardrail.id == focus ? Color.accentColor.opacity(0.16) : Color.clear, + in: RoundedRectangle(cornerRadius: 6)) + .id(guardrail.id) } } } diff --git a/Flowlight/UI/InspectView.swift b/Flowlight/UI/InspectView.swift index 4bc3ee03..950c0cc6 100644 --- a/Flowlight/UI/InspectView.swift +++ b/Flowlight/UI/InspectView.swift @@ -98,15 +98,23 @@ private struct InspectContent: View { @State private var rewriteDraft: RewriteRuleDraft? /// A response-transform rule is created only after its selected exchange's stored response has been read. @State private var responseTransformDraft: ResponseTransformDraft? - /// Setup was opened to look at the mock rules, so it opens on them. + /// Setup was opened to look at one contextual policy editor, so it opens on that section. @State private var openMocks = false + @State private var openRewrites = false @State private var openResponseTransforms = false + @State private var policyMock: MockRule? + @State private var policyRewrite: RewriteRule? + @State private var policyResponseTransform: ResponseTransformRule? + #if !APP_STORE + @State private var showPlugins = false + #endif var body: some View { VStack(alignment: .leading, spacing: 12) { if (!inspection.enabled || showSetup) && !DemoData.isEnabled { ScrollView { - InspectionSetup(inspection: inspection, openMocks: openMocks, openResponseTransforms: openResponseTransforms, done: { showSetup = false }) + InspectionSetup(inspection: inspection, openMocks: openMocks, openRewrites: openRewrites, + openResponseTransforms: openResponseTransforms, done: { showSetup = false }) .padding(.bottom, 20) } } else { @@ -149,6 +157,20 @@ private struct InspectContent: View { : L("Starting…")) : L("Off")) .searchable(text: $search, placement: .toolbar, prompt: Text(L("Host, path, app, tool or anything in a body"))) + #if !APP_STORE + .toolbar { + ToolbarItem { + Button { showPlugins = true } label: { + Label(L("Plugins & Providers"), systemImage: "puzzlepiece.extension") + } + .help(L("Manage inspection plugins and local provider profiles")) + } + } + .sheet(isPresented: $showPlugins) { + PluginSettingsView(providerProfiles: monitor.providerProfiles) + .environmentObject(monitor) + } + #endif // Arriving from a row somewhere else: the subject lands in the search field rather than in a hidden // filter, so it is visible, editable and removable like anything else typed there. .onChange(of: nav.inspectRequest, initial: true) { @@ -163,6 +185,22 @@ private struct InspectContent: View { scopeAgentParent = request.agentParent scopeMCPServer = request.mcpServer potentialHarmOnly = request.potentialHarmOnly + if let section = request.policySection { + showSetup = true + openMocks = section == .mocks + openRewrites = section == .requestRewrites + openResponseTransforms = section == .responseTransforms + if let policyID = request.policyID { + switch section { + case .mocks: + policyMock = inspection.mockRules.first { $0.id == policyID } + case .requestRewrites: + policyRewrite = inspection.rewriteRules.first { $0.id == policyID } + case .responseTransforms: + policyResponseTransform = inspection.responseTransformRules.first { $0.id == policyID } + } + } + } nav.inspectRequest = nil } .onChange(of: monitor.localRiskVersion) { @@ -185,6 +223,26 @@ private struct InspectContent: View { .sheet(item: $responseTransformDraft) { draft in ResponseTransformRuleEditor(rule: draft.rule, isNew: true, draft: draft) { inspection.responseTransformRules.append($0) } } + .sheet(item: $policyMock) { rule in + MockRuleEditor(rule: rule, isNew: false) { saved in + guard let index = inspection.mockRules.firstIndex(where: { $0.id == saved.id }) else { return } + inspection.mockRules[index] = saved + } + } + .sheet(item: $policyRewrite) { rule in + RewriteRuleEditor(rule: rule, isNew: false) { saved in + guard let index = inspection.rewriteRules.firstIndex(where: { $0.id == saved.id }) else { return } + inspection.rewriteRules[index] = saved + } + } + .sheet(item: $policyResponseTransform) { rule in + ResponseTransformRuleEditor(rule: rule, isNew: false) { saved in + guard let index = inspection.responseTransformRules.firstIndex(where: { $0.id == saved.id }) else { + return + } + inspection.responseTransformRules[index] = saved + } + } .task(id: LoadKey(version: monitor.inspectionVersion, localRiskVersion: monitor.localRiskVersion, pluginVersion: monitor.plugins.version, search: search, window: window, enabled: inspection.enabled, potentialHarmOnly: potentialHarmOnly, focus: focus.scope, app: scopeApp?.id, @@ -430,12 +488,19 @@ private struct InspectContent: View { if let guardrail = e.guardrail { Label(L("Guarded"), systemImage: "shield.lefthalf.filled").font(.caption2.bold()) .foregroundStyle(.teal).labelStyle(.titleAndIcon) - .help(L("A guardrail changed this request before it left: %@", guardrail)) + .help(L("A guardrail changed this request before it left: %@", + InspectionPolicyReference.display(guardrail))) + } + if let rewrite = e.requestRewrite { + Label(L("Request changed before sending"), systemImage: "slider.horizontal.3") + .font(.caption2.bold()).foregroundStyle(FL.tool).labelStyle(.titleAndIcon) + .help(InspectionPolicyReference.display(rewrite)) } if let transform = e.responseTransform { Label(L("Response modified"), systemImage: "arrow.left.arrow.right").font(.caption2.bold()) .foregroundStyle(.purple).labelStyle(.titleAndIcon) - .help(L("%@ answered, then Flowlight changed the response using %@ before the app received it", e.host, transform)) + .help(L("%@ answered, then Flowlight changed the response using %@ before the app received it", + e.host, InspectionPolicyReference.display(transform))) } } Text(e.path).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) @@ -510,6 +575,7 @@ private struct InspectContent: View { private struct InspectionSetup: View { @ObservedObject var inspection: InspectionController var openMocks = false + var openRewrites = false var openResponseTransforms = false var done: () -> Void @State private var newPattern = "" @@ -685,8 +751,12 @@ private struct InspectionSetup: View { .onAppear { inspection.refreshStatus() showMocks = showMocks || openMocks + showRewrites = showRewrites || openRewrites showResponseTransforms = showResponseTransforms || openResponseTransforms } + .onChange(of: openMocks) { _, open in if open { showMocks = true } } + .onChange(of: openRewrites) { _, open in if open { showRewrites = true } } + .onChange(of: openResponseTransforms) { _, open in if open { showResponseTransforms = true } } } private func ready(_ text: String, ok: Bool) -> some View { @@ -933,20 +1003,31 @@ private struct ExchangeDetail: View { } if let mock = exchange.mockRule { // The whole point of the feature is that this answer is a fiction; say so before anything below is read. - Label(L("Answered by Flowlight, not by %@ — mock rule “%@”. The request was never sent.", exchange.host, mock), - systemImage: "wand.and.stars") + Label(L("Answered by Flowlight, not by %@ — mock rule “%@”. The request was never sent.", exchange.host, + InspectionPolicyReference.display(mock)), systemImage: "wand.and.stars") .foregroundStyle(FL.tool).fixedSize(horizontal: false, vertical: true) } if let guardrail = exchange.guardrail { // The exchange really happened and really was sent — but not quite as the agent wrote it, and // anyone reading it has to be told which part Flowlight took out. Label(L("A guardrail changed this request before it left: %@. Everything below is what %@ was actually sent.", - guardrail, exchange.host), systemImage: "shield.lefthalf.filled") + InspectionPolicyReference.display(guardrail), exchange.host), systemImage: "shield.lefthalf.filled") .foregroundStyle(.teal).fixedSize(horizontal: false, vertical: true) } + if let rewrite = exchange.requestRewrite { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(L("Request changed before sending")) + Text(InspectionPolicyReference.display(rewrite)).font(.caption).foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "slider.horizontal.3") + } + .foregroundStyle(FL.tool).fixedSize(horizontal: false, vertical: true) + } if let transform = exchange.responseTransform { Label(L("%@ answered this request. Flowlight then changed the response with “%@” before the app received it.", - exchange.host, transform), systemImage: "arrow.left.arrow.right") + exchange.host, InspectionPolicyReference.display(transform)), systemImage: "arrow.left.arrow.right") .foregroundStyle(.purple).fixedSize(horizontal: false, vertical: true) } if let cause { diff --git a/Flowlight/UI/JSONTreeView.swift b/Flowlight/UI/JSONTreeView.swift index 3458803e..f8357640 100644 --- a/Flowlight/UI/JSONTreeView.swift +++ b/Flowlight/UI/JSONTreeView.swift @@ -163,6 +163,7 @@ struct JSONNodeView: View { let value: JSONValue let depth: Int var keyStyle: KeyStyle = .key + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var expanded: Bool @State private var shown = 200 @State private var fullString = false @@ -231,7 +232,7 @@ struct JSONNodeView: View { .padding(.vertical, 1.5) .contentShape(Rectangle()) .onTapGesture { - if value.isContainer { withAnimation(.easeOut(duration: 0.12)) { expanded.toggle() } } + if value.isContainer { Motion.perform(Motion.control, reduceMotion: reduceMotion) { expanded.toggle() } } else if case .string = value { fullString.toggle() } } .contextMenu { diff --git a/Flowlight/UI/LiveView.swift b/Flowlight/UI/LiveView.swift index edacf629..78e30aa2 100644 --- a/Flowlight/UI/LiveView.swift +++ b/Flowlight/UI/LiveView.swift @@ -1,53 +1,60 @@ import Charts import SwiftUI +enum LiveChartKeyboardDirection { + case left, right +} + +enum LiveChartSelection { + static func move(from current: Date?, direction: LiveChartKeyboardDirection, dates: [Date]) -> Date? { + guard !dates.isEmpty else { return nil } + guard let current, let index = dates.firstIndex(of: current) else { + return direction == .left ? dates.last : dates.first + } + switch direction { + case .left: return dates[max(0, index - 1)] + case .right: return dates[min(dates.count - 1, index + 1)] + } + } + + static func reconcile(_ current: Date?, dates: [Date]) -> Date? { + guard let current, !dates.contains(current) else { return current } + return dates.min { abs($0.timeIntervalSince(current)) < abs($1.timeIntervalSince(current)) } + } + + static func date(hovered: Date?, keyboard: Date?, chartFocused: Bool) -> Date? { + hovered ?? (chartFocused ? keyboard : nil) + } +} + struct LiveView: View { + @ObservedObject var liveState: TrafficLiveState @EnvironmentObject var monitor: TrafficMonitor @EnvironmentObject var nav: AppNavigation @State private var sortOrder = [KeyPathComparator(\LiveTalker.totalRate, order: .reverse)] @State private var selection: LiveTalker.ID? + @State private var hoveredDate: Date? + @State private var keyboardDate: Date? + @FocusState private var chartFocused: Bool + + private var highlightedPoint: SeriesPoint? { + let date = LiveChartSelection.date(hovered: hoveredDate, keyboard: keyboardDate, chartFocused: chartFocused) + return liveState.series.first { $0.date == date } + } var body: some View { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 12) { - StatTile(title: L("Download"), value: ByteFormat.rate(monitor.currentIn), systemImage: "arrow.down", tint: TrafficColors.inbound) - StatTile(title: L("Upload"), value: ByteFormat.rate(monitor.currentOut), systemImage: "arrow.up", tint: TrafficColors.outbound) - StatTile(title: L("Received this session"), value: ByteFormat.string(monitor.sessionIn), systemImage: "tray.and.arrow.down", tint: TrafficColors.inbound) - StatTile(title: L("Sent this session"), value: ByteFormat.string(monitor.sessionOut), systemImage: "tray.and.arrow.up", tint: TrafficColors.outbound) + StatTile(title: L("Download"), value: ByteFormat.rate(liveState.currentIn), systemImage: "arrow.down", tint: TrafficColors.inbound) + StatTile(title: L("Upload"), value: ByteFormat.rate(liveState.currentOut), systemImage: "arrow.up", tint: TrafficColors.outbound) + StatTile(title: L("Received this session"), value: ByteFormat.string(liveState.sessionIn), systemImage: "tray.and.arrow.down", tint: TrafficColors.inbound) + StatTile(title: L("Sent this session"), value: ByteFormat.string(liveState.sessionOut), systemImage: "tray.and.arrow.up", tint: TrafficColors.outbound) } - GroupBox { - Chart(monitor.liveSeries) { point in - AreaMark(x: .value(L("Time"), point.date), y: .value(L("Bytes/s"), point.bytesIn), series: .value(L("Direction"), "In")) - .foregroundStyle(TrafficColors.inbound.opacity(0.35)) - LineMark(x: .value(L("Time"), point.date), y: .value(L("Bytes/s"), point.bytesIn), series: .value(L("Direction"), "In")) - .foregroundStyle(TrafficColors.inbound) - AreaMark(x: .value(L("Time"), point.date), y: .value(L("Bytes/s"), -point.bytesOut), series: .value(L("Direction"), "Out")) - .foregroundStyle(TrafficColors.outbound.opacity(0.35)) - LineMark(x: .value(L("Time"), point.date), y: .value(L("Bytes/s"), -point.bytesOut), series: .value(L("Direction"), "Out")) - .foregroundStyle(TrafficColors.outbound) - RuleMark(y: .value(L("Zero"), 0)).foregroundStyle(.secondary.opacity(0.4)).lineStyle(StrokeStyle(lineWidth: 0.5)) - } - .chartYAxis { - AxisMarks { value in - AxisGridLine() - AxisValueLabel { if let v = value.as(Int64.self) { Text(ByteFormat.rate(Double(abs(v)))) } } - } - } - .frame(height: 180) - .padding(.top, 4) - } label: { - HStack(spacing: 14) { - Text(L("Last 2 minutes")) - Spacer() - Label(L("Download"), systemImage: "arrow.down").foregroundStyle(TrafficColors.inbound) - Label(L("Upload"), systemImage: "arrow.up").foregroundStyle(TrafficColors.outbound) - } - .font(.caption) - } + liveChart GroupBox { - Table(monitor.talkers.sorted(using: sortOrder), selection: $selection, sortOrder: $sortOrder) { + Table(liveState.talkers.sorted(using: sortOrder), selection: $selection, sortOrder: $sortOrder) { TableColumn(L("Application"), value: \.name) { t in HStack { AppIconView(path: t.path); Text(t.name) }.help(L("%@\nDouble-click to open in History", t.bundleID)) } @@ -68,12 +75,12 @@ struct LiveView: View { .contextMenu(forSelectionType: LiveTalker.ID.self) { ids in if let id = ids.first { Button(L("Show in History")) { openReport(id) } - InspectMenuItems(app: (id, monitor.talkers.first { $0.bundleID == id }?.name ?? id), - host: monitor.talkers.first { $0.bundleID == id }?.topDestination) - FocusMenuItems(app: (id, monitor.talkers.first { $0.bundleID == id }?.name ?? id)) + InspectMenuItems(app: (id, liveState.talkers.first { $0.bundleID == id }?.name ?? id), + host: liveState.talkers.first { $0.bundleID == id }?.topDestination) + FocusMenuItems(app: (id, liveState.talkers.first { $0.bundleID == id }?.name ?? id)) Divider() - RuleMenuItems(app: (id, monitor.talkers.first { $0.bundleID == id }?.name ?? id), - host: monitor.talkers.first { $0.bundleID == id }?.topDestination) + RuleMenuItems(app: (id, liveState.talkers.first { $0.bundleID == id }?.name ?? id), + host: liveState.talkers.first { $0.bundleID == id }?.topDestination) Button(L("Copy Bundle ID")) { NSPasteboard.general.clearContents() NSPasteboard.general.setString(id, forType: .string) @@ -91,10 +98,146 @@ struct LiveView: View { .navigationTitle(L("Live")) } + private var liveChart: some View { + let highlighted = highlightedPoint + return GroupBox { + Chart { + ForEach(liveState.series) { point in + AreaMark(x: .value(L("Time"), point.date), y: .value(L("Bytes/s"), point.bytesIn), series: .value(L("Direction"), L("Download"))) + .foregroundStyle(TrafficColors.inbound.opacity(0.12)) + LineMark(x: .value(L("Time"), point.date), y: .value(L("Bytes/s"), point.bytesIn), series: .value(L("Direction"), L("Download"))) + .foregroundStyle(TrafficColors.inbound) + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) + AreaMark(x: .value(L("Time"), point.date), y: .value(L("Bytes/s"), -point.bytesOut), series: .value(L("Direction"), L("Upload"))) + .foregroundStyle(TrafficColors.outbound.opacity(0.12)) + LineMark(x: .value(L("Time"), point.date), y: .value(L("Bytes/s"), -point.bytesOut), series: .value(L("Direction"), L("Upload"))) + .foregroundStyle(TrafficColors.outbound) + .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round, dash: [6, 3])) + } + RuleMark(y: .value(L("Zero"), 0)) + .foregroundStyle(.secondary.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 0.5)) + if let highlighted { + RuleMark(x: .value(L("Time"), highlighted.date)) + .foregroundStyle(.secondary.opacity(0.35)) + PointMark(x: .value(L("Time"), highlighted.date), y: .value(L("Bytes/s"), highlighted.bytesIn)) + .symbol(.circle).symbolSize(50).foregroundStyle(TrafficColors.inbound) + PointMark(x: .value(L("Time"), highlighted.date), y: .value(L("Bytes/s"), -highlighted.bytesOut)) + .symbol(.diamond).symbolSize(55).foregroundStyle(TrafficColors.outbound) + } + } + .chartYAxis { + AxisMarks { value in + AxisGridLine() + AxisValueLabel { if let v = value.as(Int64.self) { Text(ByteFormat.rate(Double(abs(v)))) } } + } + } + .chartOverlay { proxy in + GeometryReader { geometry in + ZStack(alignment: .topLeading) { + Rectangle().fill(.clear).contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case .active(let location): + hoveredDate = livePoint(at: location, proxy: proxy, geometry: geometry)?.date + case .ended: + hoveredDate = nil + } + } + if let highlighted, + let plot = proxy.plotFrame, + let position = proxy.position(forX: highlighted.date) { + liveTooltip(highlighted) + .offset(x: ReportChartHoverLayout.cardX( + lineX: geometry[plot].origin.x + position, + chartWidth: geometry.size.width, + cardWidth: 230 + ), y: 8) + .allowsHitTesting(false) + } + } + } + } + .focusable() + .focusEffectDisabled() + .focused($chartFocused) + .onMoveCommand { direction in + let move: LiveChartKeyboardDirection + switch direction { + case .left: move = .left + case .right: move = .right + default: return + } + hoveredDate = nil + keyboardDate = LiveChartSelection.move( + from: keyboardDate, + direction: move, + dates: liveState.series.map(\.date) + ) + } + .onChange(of: liveState.series.map(\.date)) { _, dates in + keyboardDate = LiveChartSelection.reconcile(keyboardDate, dates: dates) + } + .accessibilityLabel(L("Live network traffic")) + .accessibilityValue(highlighted.map(liveAccessibilityValue) ?? L("No time selected")) + .accessibilityHint(L("Solid circles are downloads; dashed diamonds are uploads. Use the Left and Right Arrow keys to inspect each second.")) + .accessibilityChildren { + ForEach(liveState.series) { point in + Text(liveAccessibilityValue(point)) + .accessibilityLabel(point.date.formatted(date: .omitted, time: .standard)) + .accessibilityValue(liveAccessibilityValue(point)) + } + } + .frame(height: 180) + .padding(.top, 4) + } label: { + HStack(spacing: 14) { + Text(L("Last 2 minutes")) + Spacer() + Label(L("Download · solid"), systemImage: "arrow.down") + .foregroundStyle(TrafficColors.inbound) + Label(L("Upload · dashed"), systemImage: "arrow.up") + .foregroundStyle(TrafficColors.outbound) + } + .font(.caption) + } + } + + private func livePoint(at location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> SeriesPoint? { + guard let plot = proxy.plotFrame else { return nil } + let frame = geometry[plot] + guard ReportChartHoverLayout.contains(location, in: frame), + let date: Date = proxy.value(atX: location.x - frame.origin.x) else { return nil } + return liveState.series.min { + abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) + } + } + + private func liveAccessibilityValue(_ point: SeriesPoint) -> String { + "\(point.date.formatted(date: .omitted, time: .standard)), " + + "\(L("Download")) \(ByteFormat.rate(Double(point.bytesIn))), " + + "\(L("Upload")) \(ByteFormat.rate(Double(point.bytesOut)))" + } + + private func liveTooltip(_ point: SeriesPoint) -> some View { + VStack(alignment: .leading, spacing: 5) { + Text(point.date.formatted(date: .omitted, time: .standard)).font(.caption.bold()) + Label(ByteFormat.rate(Double(point.bytesIn)), systemImage: "arrow.down.circle.fill") + .symbolRenderingMode(.hierarchical) + Label(ByteFormat.rate(Double(point.bytesOut)), systemImage: "arrow.up.diamond.fill") + .symbolRenderingMode(.hierarchical) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.primary) + .padding(8) + .frame(width: 230, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + @ViewBuilder private var emptyState: some View { - if monitor.talkers.isEmpty { - if monitor.isReceiving { + if liveState.talkers.isEmpty { + if liveState.isReceiving { ContentUnavailableView(L("The network is quiet"), systemImage: "network", description: Text(L("No app has sent or received data in the last few seconds."))) } else { diff --git a/Flowlight/UI/Motion.swift b/Flowlight/UI/Motion.swift index 81e53573..aae5114e 100644 --- a/Flowlight/UI/Motion.swift +++ b/Flowlight/UI/Motion.swift @@ -20,6 +20,17 @@ enum Motion { /// Something that repeats while a state lasts — a pulse while capture is live. static let breathing = Animation.easeInOut(duration: 1.6).repeatForever(autoreverses: true) + /// Runs an explicit state change with the shared motion policy. Views pass the system preference they read from + /// the environment so imperative scroll and disclosure changes follow the same Reduce Motion behavior as modifiers. + @MainActor + static func perform(_ animation: Animation? = control, reduceMotion: Bool, updates: () -> Void) { + if reduceMotion { + updates() + } else { + withAnimation(animation, updates) + } + } + /// How a new turn, row or card enters. Opacity plus a small rise, never a slide across, because a horizontal /// slide reads as navigation and nothing has navigated. static var entrance: AnyTransition { diff --git a/Flowlight/UI/PluginSettingsView.swift b/Flowlight/UI/PluginSettingsView.swift index 3ee433e6..eb4ab173 100644 --- a/Flowlight/UI/PluginSettingsView.swift +++ b/Flowlight/UI/PluginSettingsView.swift @@ -3,15 +3,65 @@ import SwiftUI import UniformTypeIdentifiers struct PluginSettingsView: View { + @Environment(\.dismiss) private var dismiss @EnvironmentObject var monitor: TrafficMonitor + @ObservedObject var providerProfiles: ProviderProfileStore @State private var showClearConfirmation = false @State private var message: String? @State private var configuring: PluginManifest? @State private var removing: PluginManifest? @State private var pendingReview: PluginPackageReview? + @State private var editingProfile: ProviderProfile? + @State private var removingProfile: ProviderProfile? var body: some View { Form { + Section { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(L("Plugins & Providers")).font(.title2.bold()) + Text(L("Inspection extensions, local routing metadata, and retained findings.")) + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button(L("Done")) { dismiss() }.keyboardShortcut(.defaultAction) + } + } + + Section { + if providerProfiles.profiles.isEmpty { + ContentUnavailableView { + Label(L("No provider profiles"), systemImage: "point.3.connected.trianglepath.dotted") + } description: { + Text(L("Profiles group local provider settings with the inspection plugins that use them.")) + } actions: { + Button(L("Add Provider…")) { editingProfile = ProviderProfile(name: "", provider: .flowlight) } + } + } else { + ForEach(providerProfiles.profiles) { profile in + ProviderProfileRow(profile: profile, manifests: monitor.plugins.manifests, setEnabled: { enabled in + var updated = profile + updated.enabled = enabled + saveProfile(updated) + }, edit: { + editingProfile = profile + }, remove: { + removingProfile = profile + }) + } + Button(L("Add Provider…")) { editingProfile = ProviderProfile(name: "", provider: .flowlight) } + } + if let issue = providerProfiles.loadIssue { + Label(issue, systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(FL.warning) + } + } header: { + Text(L("Provider profiles")) + } footer: { + Text(L("Profiles stay on this Mac. They contain non-secret options and optional Keychain references; Flowlight never stores credential values in a profile.")) + .font(.caption).foregroundStyle(.secondary) + } + Section { Text(L("Plugins add advisory findings to inspected exchanges. They do not block, rewrite, mock, transform, save settings, or send traffic anywhere by themselves.")) .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) @@ -33,7 +83,7 @@ struct PluginSettingsView: View { empty: L("No third-party plugin packages are installed.")) } .formStyle(.grouped) - .frame(height: 620) + .frame(minWidth: 620, idealWidth: 720, minHeight: 620, idealHeight: 680) .confirmationDialog(L("Clear plugin findings?"), isPresented: $showClearConfirmation, titleVisibility: .visible) { Button(L("Clear findings"), role: .destructive) { monitor.clearPluginFindings() } } message: { @@ -70,6 +120,38 @@ struct PluginSettingsView: View { pendingReview = nil } } + .sheet(item: $editingProfile) { profile in + ProviderProfileEditor(profile: profile, manifests: monitor.plugins.manifests) { updated in + try providerProfiles.save(updated) + message = L("Saved provider profile %@.", updated.name) + } + } + .confirmationDialog(L("Remove provider profile?"), isPresented: Binding( + get: { removingProfile != nil }, + set: { if !$0 { removingProfile = nil } } + ), titleVisibility: .visible) { + if let profile = removingProfile { + Button(L("Remove %@", profile.name), role: .destructive) { + do { + try providerProfiles.remove(id: profile.id) + message = L("Removed provider profile %@.", profile.name) + } catch { + message = L("Could not remove provider profile.") + } + removingProfile = nil + } + } + } message: { + Text(L("Plugins and their retained findings stay installed.")) + } + } + + private func saveProfile(_ profile: ProviderProfile) { + do { + try providerProfiles.save(profile) + } catch { + message = error.localizedDescription + } } private func pluginSection(_ title: String, plugins: [PluginManifest], empty: String) -> some View { @@ -137,6 +219,213 @@ struct PluginSettingsView: View { } } +private struct ProviderProfileRow: View { + var profile: ProviderProfile + var manifests: [PluginManifest] + var setEnabled: (Bool) -> Void + var edit: () -> Void + var remove: () -> Void + + private var health: ProviderProfile.Health { profile.health(manifests: manifests) } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle(isOn: Binding(get: { profile.enabled }, set: setEnabled)) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(profile.name).font(.callout.bold()) + PluginPill(profile.provider.title) + PluginPill(health.title) + } + Label(health.reason, systemImage: healthIcon) + .font(.caption) + .foregroundStyle(healthColor) + if let endpoint = profile.endpoint { + Text(endpoint).font(.caption2.monospaced()).foregroundStyle(.tertiary).lineLimit(1) + } + if profile.credentialReference != nil { + Label(L("Credential value kept in Keychain"), systemImage: "key.fill") + .font(.caption2).foregroundStyle(.tertiary) + } + } + } + .toggleStyle(.switch) + HStack(spacing: 8) { + Button(L("Edit…"), action: edit) + Button(L("Remove"), role: .destructive, action: remove) + } + .font(.caption) + } + .padding(.vertical, 2) + } + + private var healthIcon: String { + switch health { + case .disabled: return "pause.circle" + case .attention: return "exclamationmark.triangle.fill" + case .ready: return "checkmark.circle.fill" + } + } + + private var healthColor: Color { + switch health { + case .disabled: return .secondary + case .attention: return FL.warning + case .ready: return FL.good + } + } +} + +private struct ProviderProfileEditor: View { + @Environment(\.dismiss) private var dismiss + @State private var profile: ProviderProfile + @State private var optionRows: [OptionRow] + @State private var error: String? + var manifests: [PluginManifest] + var save: (ProviderProfile) throws -> Void + + init(profile: ProviderProfile, manifests: [PluginManifest], save: @escaping (ProviderProfile) throws -> Void) { + _profile = State(initialValue: profile) + _optionRows = State(initialValue: profile.configuration.sorted { $0.key < $1.key }.map { OptionRow(key: $0.key, value: $0.value) }) + self.manifests = manifests + self.save = save + } + + private var compatiblePlugins: [PluginManifest] { + manifests.filter { $0.guardrailProvider == profile.provider }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(profile.name.isEmpty ? L("Add Provider Profile") : L("Edit Provider Profile")) + .font(.headline) + Form { + Section(L("Identity")) { + TextField(L("Name"), text: $profile.name) + Picker(L("Provider"), selection: $profile.provider) { + ForEach(PluginManifest.GuardrailProvider.allCases, id: \.self) { provider in + Text(provider.title).tag(provider) + } + } + Toggle(L("Enabled"), isOn: $profile.enabled) + } + + Section { + TextField(L("Endpoint (optional)"), text: Binding( + get: { profile.endpoint ?? "" }, + set: { profile.endpoint = $0.isEmpty ? nil : $0 } + )) + TextField(L("Keychain reference (optional)"), text: Binding( + get: { profile.credentialReference ?? "" }, + set: { profile.credentialReference = $0.isEmpty ? nil : $0 } + )) + Text(L("Use keychain:service/account. The credential value is never stored in this profile.")) + .font(.caption).foregroundStyle(.secondary) + } header: { + Text(L("Connection")) + } + + Section { + if compatiblePlugins.isEmpty { + Text(L("No installed plugins use this provider.")) + .font(.caption).foregroundStyle(.secondary) + } else { + ForEach(compatiblePlugins) { plugin in + Toggle(isOn: assignment(for: plugin.id)) { + VStack(alignment: .leading, spacing: 2) { + Text(plugin.name) + Text(plugin.enabled ? L("Plugin enabled") : L("Plugin disabled")) + .font(.caption).foregroundStyle(.secondary) + } + } + } + } + } header: { + Text(L("Inspection plugins")) + } + + Section { + ForEach($optionRows) { $row in + HStack(spacing: 8) { + TextField(L("Option"), text: $row.key) + TextField(L("Value"), text: $row.value) + Button { + optionRows.removeAll { $0.id == row.id } + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .accessibilityLabel(L("Remove provider option")) + } + } + Button(L("Add Option")) { optionRows.append(OptionRow()) } + Text(L("Options are local non-secret metadata. API keys, tokens, passwords, and other credentials are rejected.")) + .font(.caption).foregroundStyle(.secondary) + } header: { + Text(L("Provider options")) + } + } + .formStyle(.grouped) + .onChange(of: profile.provider) { + profile.pluginIDs.removeAll() + } + + if let error { + Label(error, systemImage: "exclamationmark.triangle") + .font(.caption).foregroundStyle(FL.warning) + } + HStack { + Spacer() + Button(L("Cancel")) { dismiss() } + Button(L("Save"), action: saveProfile) + .keyboardShortcut(.defaultAction) + .disabled(profile.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(20) + .frame(width: 600, height: 650) + } + + private func assignment(for pluginID: String) -> Binding { + Binding(get: { + profile.pluginIDs.contains(pluginID) + }, set: { assigned in + if assigned { + if !profile.pluginIDs.contains(pluginID) { profile.pluginIDs.append(pluginID) } + } else { + profile.pluginIDs.removeAll { $0 == pluginID } + } + }) + } + + private func saveProfile() { + var configuration: [String: String] = [:] + for row in optionRows { + let key = row.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + configuration[key] = row.value + } + profile.configuration = configuration + do { + try save(profile) + dismiss() + } catch { + self.error = error.localizedDescription + } + } + + private struct OptionRow: Identifiable { + let id = UUID() + var key = "" + var value = "" + + init(key: String = "", value: String = "") { + self.key = key + self.value = value + } + } +} + private struct PluginSettingsRow: View { var plugin: PluginManifest var setEnabled: (Bool) -> Void diff --git a/Flowlight/UI/ReportsView.swift b/Flowlight/UI/ReportsView.swift index f60f974c..31e18ee8 100644 --- a/Flowlight/UI/ReportsView.swift +++ b/Flowlight/UI/ReportsView.swift @@ -146,6 +146,11 @@ enum ReportChartHoverLayout { : lineX - gap - cardWidth return min(max(0, proposed), max(0, chartWidth - cardWidth)) } + + static func contains(_ location: CGPoint, in plotFrame: CGRect) -> Bool { + location.x > plotFrame.minX && location.x < plotFrame.maxX + && location.y > plotFrame.minY && location.y < plotFrame.maxY + } } enum ReportChartKeyboardDirection { @@ -161,6 +166,17 @@ enum ReportChartKeyboardSelection { case .right: return dates[min(dates.count - 1, index + 1)] } } + + static func reconcile(_ current: Date?, chartFocused: Bool, dates: [Date]) -> Date? { + guard chartFocused, let current, !dates.contains(current) else { return current } + return move(from: nil, direction: .right, dates: dates) + } +} + +enum ReportChartHighlightSelection { + static func date(hovered: Date?, keyboard: Date?, chartFocused: Bool) -> Date? { + hovered ?? (chartFocused ? keyboard : nil) + } } struct ReportNavigationState: Equatable { @@ -252,10 +268,12 @@ struct ReportsView: View { /// From the series, not the table rows, so shares stay right even if the table is capped. private var grandTotal: Int64 { max(1, series.reduce(0) { $0 + $1.total }) } private var highlightedPoint: SeriesPoint? { - if chartFocused, let keyboardBucketDate { - return series.first { $0.date == keyboardBucketDate } - } - return hovered + let date = ReportChartHighlightSelection.date( + hovered: hovered?.date, + keyboard: keyboardBucketDate, + chartFocused: chartFocused + ) + return series.first { $0.date == date } } var body: some View { @@ -701,9 +719,12 @@ struct ReportsView: View { case .active(let location): hoverDismissID = UUID() tooltipHovered = false - hovered = point(at: location, proxy: proxy, geo: geo) - hoverLineX = hovered.flatMap { lineX(for: $0, proxy: proxy, geo: geo) } - ?? location.x + if let point = point(at: location, proxy: proxy, geo: geo) { + hovered = point + hoverLineX = lineX(for: point, proxy: proxy, geo: geo) ?? location.x + } else { + clearTransientChartHover() + } case .ended: scheduleHoverDismissal() } @@ -713,17 +734,12 @@ struct ReportsView: View { .focusable() .focusEffectDisabled() .focused($chartFocused) - .onChange(of: chartFocused) { _, focused in - guard focused else { return } - keyboardBucketDate = ReportChartKeyboardSelection.move( - from: keyboardBucketDate, - direction: .right, - dates: series.map(\.date) - ) - } .onChange(of: series.map(\.date)) { _, dates in - guard chartFocused, !dates.contains(keyboardBucketDate ?? .distantPast) else { return } - keyboardBucketDate = ReportChartKeyboardSelection.move(from: nil, direction: .right, dates: dates) + keyboardBucketDate = ReportChartKeyboardSelection.reconcile( + keyboardBucketDate, + chartFocused: chartFocused, + dates: dates + ) } .onMoveCommand { direction in let keyboardDirection: ReportChartKeyboardDirection @@ -770,6 +786,9 @@ struct ReportsView: View { } } } + .onHover { inside in + if !inside { clearTransientChartHover() } + } .frame(height: 300) HistoryNavigator(points: overview, contextPoints: navigatorDetail, available: availableRange, selection: window, onCommit: apply) @@ -1107,8 +1126,9 @@ struct ReportsView: View { private func point(at location: CGPoint, proxy: ChartProxy, geo: GeometryProxy) -> SeriesPoint? { guard let plot = proxy.plotFrame else { return nil } - let origin = geo[plot].origin - guard let date: Date = proxy.value(atX: location.x - origin.x) else { return nil } + let frame = geo[plot] + guard ReportChartHoverLayout.contains(location, in: frame), + let date: Date = proxy.value(atX: location.x - frame.origin.x) else { return nil } return series.last { $0.date <= date } ?? series.first } @@ -1118,14 +1138,20 @@ struct ReportsView: View { return geo[plot].origin.x + x } + private func clearTransientChartHover() { + hoverDismissID = UUID() + tooltipHovered = false + hovered = nil + hoverLineX = nil + } + private func scheduleHoverDismissal() { let requestID = UUID() hoverDismissID = requestID Task { @MainActor in try? await Task.sleep(for: .milliseconds(180)) guard hoverDismissID == requestID, !tooltipHovered else { return } - hovered = nil - hoverLineX = nil + clearTransientChartHover() } } diff --git a/Flowlight/UI/RulePacksView.swift b/Flowlight/UI/RulePacksView.swift index 1b45d6cd..54386f57 100644 --- a/Flowlight/UI/RulePacksView.swift +++ b/Flowlight/UI/RulePacksView.swift @@ -297,7 +297,7 @@ private struct RulePacksContent: View { } } -private struct RulePackEditor: View { +struct RulePackEditor: View { let pack: RulePackDefinition let receipt: RulePackReviewReceipt? @EnvironmentObject var monitor: TrafficMonitor diff --git a/Flowlight/UI/RulesView.swift b/Flowlight/UI/RulesView.swift index 94d6323d..0f663fa5 100644 --- a/Flowlight/UI/RulesView.swift +++ b/Flowlight/UI/RulesView.swift @@ -1,6 +1,433 @@ import SwiftUI -/// Every rule Flowlight has been given, what each one is doing, and what they have refused. +struct PolicyInventoryCapabilities: Equatable, Sendable { + var inspectionPolicies: Bool + + static var current: PolicyInventoryCapabilities { + #if APP_STORE + PolicyInventoryCapabilities(inspectionPolicies: false) + #else + PolicyInventoryCapabilities(inspectionPolicies: true) + #endif + } +} + +enum PolicyInventoryKind: String, CaseIterable, Sendable { + case networkRule = "rule" + case agentAllowlist = "allowlist" + case managedPack = "pack" + case guardrail + case mock + case requestRewrite = "request-rewrite" + case responseTransform = "response-transform" + + var requiresInspection: Bool { + switch self { + case .guardrail, .mock, .requestRewrite, .responseTransform: return true + case .networkRule, .agentAllowlist, .managedPack: return false + } + } + + @MainActor var sectionTitle: String { + switch self { + case .networkRule: return L("Network rules") + case .agentAllowlist: return L("Agent allowlists") + case .managedPack: return L("Managed packs") + case .guardrail: return L("Agent guardrails") + case .mock: return L("Mock responses") + case .requestRewrite: return L("Request rewrites") + case .responseTransform: return L("Response transforms") + } + } + + var symbol: String { + switch self { + case .networkRule: return "hand.raised" + case .agentAllowlist: return "checklist" + case .managedPack: return "shippingbox" + case .guardrail: return "wrench.and.screwdriver" + case .mock: return "wand.and.stars" + case .requestRewrite: return "slider.horizontal.3" + case .responseTransform: return "arrow.left.arrow.right" + } + } +} + +enum PolicyInventoryID { + static func make(_ kind: PolicyInventoryKind, sourceID: String) -> String { + "\(kind.rawValue):\(sourceID)" + } +} + +enum PolicyEffectState: Equatable, Sendable { + case effective + case reportingOnly + case watchingOnly + case waiting + case off + case needsReview + case unavailable + case failed + + var isEffective: Bool { + switch self { + case .effective, .reportingOnly, .watchingOnly: return true + case .waiting, .off, .needsReview, .unavailable, .failed: return false + } + } + + @MainActor var title: String { + switch self { + case .effective: return L("In effect") + case .reportingOnly: return L("Reporting only") + case .watchingOnly: return L("Watching only") + case .waiting: return L("Waiting") + case .off: return L("Off") + case .needsReview: return L("Needs review") + case .unavailable: return L("Unavailable") + case .failed: return L("Not applying") + } + } +} + +struct PolicyInventoryItem: Identifiable, Equatable, Sendable { + var kind: PolicyInventoryKind + var sourceID: String + var title: String + var detail: String + /// The persisted switch or configuration intent. This is not evidence that the policy can currently act. + var isEnabled: Bool + var effect: PolicyEffectState + var reason: String? + var destination: PolicyDestination + var id: String { PolicyInventoryID.make(kind, sourceID: sourceID) } + + func isVisible(in capabilities: PolicyInventoryCapabilities) -> Bool { + capabilities.inspectionPolicies || !kind.requiresInspection + } +} + +enum PolicyInventoryBuilder { + @MainActor + static func build(rules: [Rule], allowlists: [String: AgentPolicy], packs: [RulePackDefinition], + unavailablePacks: [RulePackConfiguration] = [], + packConfiguration: (String) -> RulePackConfiguration, + packNeedsReview: (RulePackDefinition) -> Bool = { _ in false }, + packStatus: RulePackStore.ApplyStatus = .off, + guardrails: [Guardrail], mocks: [MockRule], rewrites: [RewriteRule], + transforms: [ResponseTransformRule], extensionRunning: Bool = true, + canBlock: Bool = true, inspectionEnabled: Bool = true, inspectionRunning: Bool = true, + rulesPaused: Bool = false, now: Date = Date(), ruleSession: String = RuleStore.session, + capabilities: PolicyInventoryCapabilities = .current) -> [PolicyInventoryItem] { + let inspectionReason = !inspectionEnabled ? L("HTTPS inspection is off.") : L("HTTPS inspection is not running.") + func inspectionEffect(_ enabled: Bool) -> (PolicyEffectState, String?) { + guard enabled else { return (.off, L("This policy is switched off.")) } + return inspectionEnabled && inspectionRunning ? (.effective, nil) : (.waiting, inspectionReason) + } + + var items = rules.map { rule -> PolicyInventoryItem in + let effect: PolicyEffectState + let reason: String? + if !rule.enabled { + (effect, reason) = (.off, L("This rule is switched off.")) + } else if rulesPaused { + (effect, reason) = (.waiting, L("Network blocking is paused.")) + } else if !rule.isUsable { + (effect, reason) = (.waiting, L("This one-off rule has already been used.")) + } else if !rule.schedule.isActive(at: now, session: ruleSession) { + (effect, reason) = (.waiting, L("This rule is outside its scheduled time.")) + } else if let limitation = RuleWords.limitation(rule, extensionRunning: extensionRunning, + inspecting: inspectionEnabled && inspectionRunning) { + (effect, reason) = (.watchingOnly, limitation) + } else { + (effect, reason) = (.effective, nil) + } + return PolicyInventoryItem(kind: .networkRule, sourceID: rule.id.uuidString.lowercased(), + title: RuleWords.title(rule), + detail: rule.destination.isEmpty ? L("Every destination") : rule.destination, + isEnabled: rule.enabled, effect: effect, reason: reason, + destination: .rule(rule.id)) + } + items += allowlists.keys.sorted().compactMap { agentID in + guard let policy = allowlists[agentID] else { return nil } + let detail = policy.patterns.isEmpty ? L("No listed destinations") + : (policy.patterns.count == 1 ? L("1 listed destination") : L("%lld listed destinations", policy.patterns.count)) + let effect: PolicyEffectState + let reason: String? + if !policy.enabled { + (effect, reason) = (.off, L("This allowlist is switched off.")) + } else if !policy.enforce { + (effect, reason) = (.reportingOnly, L("Unlisted destinations raise alerts but are not blocked.")) + } else if canBlock { + (effect, reason) = (.effective, nil) + } else { + (effect, reason) = (.reportingOnly, L("Blocking is unavailable; unlisted destinations still raise alerts.")) + } + return PolicyInventoryItem(kind: .agentAllowlist, sourceID: agentID, title: agentID, detail: detail, + isEnabled: policy.enabled, effect: effect, reason: reason, + destination: .allowlist(agentID)) + } + items += packs.map { pack in + let configuration = packConfiguration(pack.id) + let effect: PolicyEffectState + let reason: String? + if packNeedsReview(pack) { + (effect, reason) = (.needsReview, L("Review the changed pack before it can keep applying.")) + } else if !configuration.enabled { + (effect, reason) = (.off, L("This pack is switched off.")) + } else { + switch packStatus { + case .enforcing where canBlock: (effect, reason) = (.effective, nil) + case .watchingOnly, .enforcing: + (effect, reason) = (.watchingOnly, L("The pack is matching destinations but cannot block them here.")) + case .applying, .ready: + (effect, reason) = (.waiting, L("The pack is waiting for the active filter to apply it.")) + case .incompatible(let message), .failed(let message): + (effect, reason) = (.failed, message) + case .off: + (effect, reason) = (.waiting, L("The managed-pack engine is not applying this configuration.")) + } + } + return PolicyInventoryItem(kind: .managedPack, sourceID: pack.id, title: L(pack.manifest.titleKey), + detail: L("%lld destinations", pack.entries.count), + isEnabled: configuration.enabled, effect: effect, reason: reason, + destination: .managedPack(pack.id)) + } + let knownPackIDs = Set(packs.map(\.id)) + items += unavailablePacks.filter { !knownPackIDs.contains($0.packID) }.map { configuration in + PolicyInventoryItem(kind: .managedPack, sourceID: configuration.packID, title: configuration.packID, + detail: L("No longer in the official catalog"), isEnabled: configuration.enabled, + effect: .unavailable, + reason: L("Blocking stopped automatically. Acknowledge this change after review."), + destination: .managedPack(configuration.packID)) + } + items += guardrails.map { guardrail in + let (effect, reason) = inspectionEffect(guardrail.enabled) + return PolicyInventoryItem(kind: .guardrail, sourceID: guardrail.id.uuidString.lowercased(), + title: guardrail.localizedTitle, + detail: guardrail.agent.isEmpty ? L("Every agent") : guardrail.agent, + isEnabled: guardrail.enabled, effect: effect, reason: reason, + destination: .guardrail(id: guardrail.id, agentID: guardrail.agent)) + } + items += mocks.map { rule in + let (effect, reason) = inspectionEffect(rule.enabled) + return PolicyInventoryItem(kind: .mock, sourceID: rule.id.uuidString.lowercased(), title: rule.title, + detail: "\(rule.method.isEmpty ? "ANY" : rule.method.uppercased()) \(rule.host)\(rule.path)", + isEnabled: rule.enabled, effect: effect, reason: reason, + destination: .mock(rule.id)) + } + items += rewrites.map { rule in + let (effect, reason) = inspectionEffect(rule.enabled) + return PolicyInventoryItem(kind: .requestRewrite, sourceID: rule.id.uuidString.lowercased(), title: rule.title, + detail: "\(rule.method.isEmpty ? "ANY" : rule.method.uppercased()) \(rule.host)\(rule.path)", + isEnabled: rule.enabled, effect: effect, reason: reason, + destination: .requestRewrite(rule.id)) + } + items += transforms.map { rule in + let (effect, reason) = inspectionEffect(rule.enabled) + return PolicyInventoryItem(kind: .responseTransform, sourceID: rule.id.uuidString.lowercased(), title: rule.title, + detail: "\(rule.method.isEmpty ? "ANY" : rule.method.uppercased()) \(rule.host)\(rule.path)", + isEnabled: rule.enabled, effect: effect, reason: reason, + destination: .responseTransform(rule.id)) + } + return items.filter { $0.isVisible(in: capabilities) } + } +} + +enum PolicyActivityEffect: String, Equatable, Hashable, Sendable { + case blocked + case allowed + case packBlocked = "pack-blocked" + case allowlistViolation = "allowlist-violation" + case guardrailApplied = "guardrail-applied" + case mocked + case requestRewritten = "request-rewritten" + case responseTransformed = "response-transformed" +} + +struct PolicyActivityItem: Identifiable, Equatable, Sendable { + var id: String + var timestamp: Date + var kind: PolicyInventoryKind + var effect: PolicyActivityEffect + var title: String + var detail: String + var policyID: String? +} + +enum PolicyActivityBuilder { + private struct RefusalKey: Hashable { + var bundleID: String + var destination: String + var port: Int + var path: String + var method: String + } + + private struct GuardrailRefusalKey: Hashable { + var bundleID: String + var destination: String + var port: Int + var method: String + } + + static func build(ruleEvents: [RuleEventRecord], packEvents: [RulePackEventRecord], alerts: [AlertRecord], + exchanges: [HTTPExchange], rules: [Rule], guardrails: [Guardrail], packs: [RulePackDefinition], + mocks: [MockRule], rewrites: [RewriteRule], transforms: [ResponseTransformRule], + policies: [AgentPolicy] = [], + capabilities: PolicyInventoryCapabilities = .current) -> [PolicyActivityItem] { + let ruleIDs = Set(rules.map(\.id)) + let guardrailIDs = Set(guardrails.map(\.id)) + let packIDs = Set(packs.map(\.id)) + let mockIDs = Set(mocks.map(\.id)) + let rewriteIDs = Set(rewrites.map(\.id)) + let transformIDs = Set(transforms.map(\.id)) + let refusalTimes = Dictionary(grouping: ruleEvents.filter { + $0.action == .block && $0.engine != .guardrail + }) { event in + RefusalKey(bundleID: event.bundleID, destination: event.destination, port: Int(event.port), + path: event.path, method: event.method) + }.mapValues { $0.map(\.timestamp) } + let guardrailRefusalTimes = Dictionary(grouping: ruleEvents.filter { + $0.action == .block && $0.engine == .guardrail + }) { event in + GuardrailRefusalKey(bundleID: event.bundleID, destination: event.destination, + port: Int(event.port), method: event.method) + }.mapValues { $0.map(\.timestamp) } + var activity = ruleEvents.map { event -> PolicyActivityItem in + let isGuardrail = event.engine == .guardrail || guardrailIDs.contains(event.ruleID) + let title = rules.first(where: { $0.id == event.ruleID }).map(RuleWords.title) + ?? guardrails.first(where: { $0.id == event.ruleID })?.localizedTitle ?? L("A policy since deleted") + let exists = isGuardrail ? guardrailIDs.contains(event.ruleID) : ruleIDs.contains(event.ruleID) + return PolicyActivityItem( + id: "rule-event:\(event.id)", timestamp: event.timestamp, + kind: isGuardrail ? .guardrail : .networkRule, + effect: isGuardrail ? .guardrailApplied : (event.action == .block ? .blocked : .allowed), + title: title, detail: event.path.isEmpty ? "\(event.destination):\(event.port)" : "\(event.method) \(event.destination)\(event.path)", + policyID: exists ? PolicyInventoryID.make(isGuardrail ? .guardrail : .networkRule, + sourceID: event.ruleID.uuidString.lowercased()) : nil) + } + activity += packEvents.map { event in + let title = packs.first(where: { $0.id == event.packID }).map { L($0.manifest.titleKey) } ?? event.packID + return PolicyActivityItem(id: "pack-event:\(event.id)", timestamp: event.lastAt, kind: .managedPack, + effect: .packBlocked, + title: title, detail: L("%@ · %lld connections", event.host, event.count), + policyID: packIDs.contains(event.packID) + ? PolicyInventoryID.make(.managedPack, sourceID: event.packID) : nil) + } + activity += alerts.filter { $0.kind == AnomalyEngine.Kind.allowlistViolation.rawValue }.map { alert in + let policy = AgentPolicy.resolve( + policies, + identities: [alert.bundleID, alert.appName] + ) + return PolicyActivityItem( + id: "alert:\(alert.id)", timestamp: alert.timestamp, kind: .agentAllowlist, + effect: .allowlistViolation, + title: alert.appName, detail: alert.detail, + policyID: policy.map { + PolicyInventoryID.make(.agentAllowlist, sourceID: $0.agentID) + } + ) + } + for exchange in exchanges { + let source = exchange.id.map(String.init) ?? "\(exchange.started.timeIntervalSince1970)" + let detail = "\(exchange.method) \(exchange.host)\(exchange.path)" + if let value = exchange.guardrail { + if let references = InspectionPolicyReference.decode(value) { + for reference in references { + activity.append(PolicyActivityItem( + id: "exchange:\(source):guardrail:\(reference.id.uuidString.lowercased())", + timestamp: exchange.started, kind: .guardrail, effect: .guardrailApplied, + title: reference.title, detail: detail, + policyID: guardrailIDs.contains(reference.id) + ? PolicyInventoryID.make(.guardrail, sourceID: reference.id.uuidString.lowercased()) : nil + )) + } + } else { + let policy = guardrails.first { $0.localizedTitle == value || $0.title == value } + activity.append(PolicyActivityItem(id: "exchange:\(source):guardrail", timestamp: exchange.started, + kind: .guardrail, effect: .guardrailApplied, title: value, detail: detail, + policyID: policy.map { PolicyInventoryID.make(.guardrail, sourceID: $0.id.uuidString.lowercased()) })) + } + } + if let value = exchange.mockRule { + if let references = InspectionPolicyReference.decode(value) { + for reference in references { + activity.append(PolicyActivityItem( + id: "exchange:\(source):mock:\(reference.id.uuidString.lowercased())", + timestamp: exchange.started, kind: .mock, effect: .mocked, + title: reference.title, detail: detail, + policyID: mockIDs.contains(reference.id) + ? PolicyInventoryID.make(.mock, sourceID: reference.id.uuidString.lowercased()) : nil + )) + } + } else { + let policy = mocks.first(where: { $0.title == value }) + let exchangePath = exchange.path.split(separator: "?", maxSplits: 1).first.map(String.init) ?? exchange.path + let refusalKey = RefusalKey(bundleID: exchange.bundleID, destination: exchange.host, + port: exchange.port, path: exchangePath, method: exchange.method) + let guardrailKey = GuardrailRefusalKey(bundleID: exchange.bundleID, + destination: exchange.host, port: exchange.port, + method: exchange.method) + let hasRefusalEvent = (refusalTimes[refusalKey] ?? []) + .contains { abs($0.timeIntervalSince(exchange.started)) <= 1 } + || (guardrailRefusalTimes[guardrailKey] ?? []) + .contains { abs($0.timeIntervalSince(exchange.started)) <= 1 } + // Before stable mock references, request-level blocks and guardrail refusals used the same answer + // machinery. Their correlated RuleEvent is already in Activity; an uncorrelated value is retained + // as mock history even after its current configuration is renamed or deleted. + if policy != nil || !hasRefusalEvent { + activity.append(PolicyActivityItem( + id: "exchange:\(source):mock", timestamp: exchange.started, + kind: .mock, effect: .mocked, title: value, detail: detail, + policyID: policy.map { PolicyInventoryID.make(.mock, sourceID: $0.id.uuidString.lowercased()) } + )) + } + } + } + if let value = exchange.requestRewrite { + if let references = InspectionPolicyReference.decode(value) { + for reference in references { + activity.append(PolicyActivityItem( + id: "exchange:\(source):request-rewrite:\(reference.id.uuidString.lowercased())", + timestamp: exchange.started, kind: .requestRewrite, effect: .requestRewritten, + title: reference.title, detail: detail, + policyID: rewriteIDs.contains(reference.id) + ? PolicyInventoryID.make(.requestRewrite, sourceID: reference.id.uuidString.lowercased()) : nil + )) + } + } else { + let policy = rewrites.first { $0.title == value } + activity.append(PolicyActivityItem(id: "exchange:\(source):request-rewrite", timestamp: exchange.started, + kind: .requestRewrite, effect: .requestRewritten, title: value, detail: detail, + policyID: policy.map { PolicyInventoryID.make(.requestRewrite, sourceID: $0.id.uuidString.lowercased()) })) + } + } + if let value = exchange.responseTransform { + if let references = InspectionPolicyReference.decode(value) { + for reference in references { + activity.append(PolicyActivityItem( + id: "exchange:\(source):response-transform:\(reference.id.uuidString.lowercased())", + timestamp: exchange.started, kind: .responseTransform, effect: .responseTransformed, + title: reference.title, detail: detail, + policyID: transformIDs.contains(reference.id) + ? PolicyInventoryID.make(.responseTransform, sourceID: reference.id.uuidString.lowercased()) : nil + )) + } + } else { + let policy = transforms.first { $0.title == value } + activity.append(PolicyActivityItem(id: "exchange:\(source):response-transform", timestamp: exchange.started, + kind: .responseTransform, effect: .responseTransformed, title: value, detail: detail, + policyID: policy.map { PolicyInventoryID.make(.responseTransform, sourceID: $0.id.uuidString.lowercased()) })) + } + } + } + return activity.filter { capabilities.inspectionPolicies || !$0.kind.requiresInspection } + .sorted { ($0.timestamp, $0.id) > ($1.timestamp, $1.id) } + } +} + +/// Every policy Flowlight has been given, what each one is doing, and what it has changed. /// /// The screen answers three questions, in the order people ask them: what have I said, is it actually happening, /// and why is this getting through? The last two are why the list carries a limitation beside a rule the current @@ -8,20 +435,35 @@ import SwiftUI struct RulesView: View { @EnvironmentObject var monitor: TrafficMonitor @EnvironmentObject var nav: AppNavigation + @Environment(\.accessibilityReduceMotion) private var reduceMotion @ObservedObject private var templates = RuleTemplateRequests.shared @State private var sheet: Sheet? @State private var now = Date() + @State private var allowlists: [String: AgentPolicy] = [:] + @State private var alerts: [AlertRecord] = [] + @State private var exchanges: [HTTPExchange] = [] + @State private var hasMorePolicyEffects = false + @State private var loadingMorePolicyEffects = false + @State private var policyEffectSince = Date.distantPast + @State private var focusPolicyID: String? + @State private var packSaveErrors: [String: String] = [:] + + private static let policyEffectPageSize = 500 /// One sheet at a time, named rather than implied by two booleans — the editor and the library are alternatives /// and the type should say so. private enum Sheet: Identifiable { case editor(Rule, isNew: Bool) case library(RuleTemplate.ID?, RuleTemplate.Subject) + case pack(RulePackDefinition, RulePackEditorDraft.Intent) + case globalGuardrails(UUID?) var id: String { switch self { case .editor(let rule, _): return "editor-\(rule.id)" case .library(let focus, _): return "library-\(focus ?? "all")" + case .pack(let pack, let intent): return "pack-\(pack.id)-\(intent)" + case .globalGuardrails(let focus): return "global-guardrails-\(focus?.uuidString ?? "all")" } } } @@ -31,15 +473,11 @@ struct RulesView: View { var body: some View { Group { switch nav.rulesTab { - case .rules: - if store.rules.isEmpty { empty } else { ruleList } - case .packs: - RulePacksView() - case .activity: - activityFeed + case .rules: inventory + case .activity: activityFeed } } - .navigationTitle(L("Rules")) + .navigationTitle(L("Policies")) // The title bar carries the state that would otherwise need a trip to the screen to discover: how many // rules are live, and whether they are standing down. .navigationSubtitle(subtitle) @@ -55,6 +493,12 @@ struct RulesView: View { RuleTemplateLibrary(focus: focus, subject: subject) { rules in for rule in rules { store.save(rule) } } + case .pack(let pack, let intent): + RulePackEditor(pack: pack, receipt: monitor.rulePacks.reviewReceipt(for: pack), + saved: monitor.rulePacks.configuration(for: pack.id), intent: intent, + packs: monitor.rulePacks, rules: store) + case .globalGuardrails(let focus): + GlobalGuardrailsSheet(store: monitor.guardrails, focus: focus) } } .onChange(of: nav.ruleRequest) { _, request in @@ -63,6 +507,12 @@ struct RulesView: View { nav.ruleRequest = nil } .onChange(of: templates.pending?.id) { _, _ in takeTemplateRequest() } + .onChange(of: nav.policyRequest, initial: true) { _, request in + guard let request else { return } + handle(request.destination) + nav.policyRequest = nil + } + .task(id: monitor.dataVersion) { await loadPolicySources() } .onAppear { if let request = nav.ruleRequest { sheet = .editor(request.rule, isNew: true) @@ -81,28 +531,113 @@ struct RulesView: View { templates.pending = nil } + private func handle(_ destination: PolicyDestination) { + nav.rulesTab = .rules + switch destination { + case .rule(let id): + if let rule = store.rules.first(where: { $0.id == id }) { sheet = .editor(rule, isNew: false) } + case .managedPack(let packID): + focusPolicyID = packID.map { PolicyInventoryID.make(.managedPack, sourceID: $0) } ?? "managed-packs" + if let packID, let pack = monitor.rulePacks.definitions.first(where: { $0.id == packID }) { + sheet = .pack(pack, .review) + } + case .allowlist(let agentID): + nav.showAgentPolicy(agentID: agentID, section: .allowlist) + case .guardrail(let id, let agentID): + if agentID.isEmpty { + sheet = .globalGuardrails(id) + } else { + nav.showAgentPolicy(agentID: agentID, section: .guardrails, guardrailID: id) + } + case .mock(let id): + nav.showInspectionPolicy(.mocks, policyID: id) + case .requestRewrite(let id): + nav.showInspectionPolicy(.requestRewrites, policyID: id) + case .responseTransform(let id): + nav.showInspectionPolicy(.responseTransforms, policyID: id) + } + } + + private func loadPolicySources() async { + let since = Date().addingTimeInterval(-30 * 86_400) + let pageSize = Self.policyEffectPageSize + let loaded = try? await monitor.read { db in + (try db.loadPolicies(), + try db.alerts(limit: 2_000, kind: AnomalyEngine.Kind.allowlistViolation.rawValue), + try db.exchanges(since: since, limit: pageSize + 1, policyEffectsOnly: true)) + } + guard let loaded else { return } + allowlists = loaded.0 + alerts = loaded.1 + exchanges = Array(loaded.2.prefix(pageSize)) + hasMorePolicyEffects = loaded.2.count > pageSize + policyEffectSince = since + loadingMorePolicyEffects = false + } + + private func loadMorePolicyEffects() async { + guard hasMorePolicyEffects, !loadingMorePolicyEffects else { return } + loadingMorePolicyEffects = true + let pageSize = Self.policyEffectPageSize + let since = policyEffectSince + guard let cursor = exchanges.last, let cursorID = cursor.id else { + hasMorePolicyEffects = false + loadingMorePolicyEffects = false + return + } + guard let page = try? await monitor.read({ db in + try db.exchanges(since: since, limit: pageSize + 1, + before: cursor.started, beforeID: cursorID, policyEffectsOnly: true) + }) else { + loadingMorePolicyEffects = false + return + } + let known = Set(exchanges.compactMap(\.id)) + exchanges.append(contentsOf: page.prefix(pageSize).filter { exchange in + guard let id = exchange.id else { return true } + return !known.contains(id) + }) + hasMorePolicyEffects = page.count > pageSize + loadingMorePolicyEffects = false + } + + private var inventoryItems: [PolicyInventoryItem] { + PolicyInventoryBuilder.build(rules: store.rules, allowlists: allowlists, + packs: monitor.rulePacks.definitions, + unavailablePacks: monitor.rulePacks.unavailableReviewConfigurations, + packConfiguration: { monitor.rulePacks.configuration(for: $0) }, + packNeedsReview: monitor.rulePacks.needsReview, + packStatus: monitor.rulePacks.status, + guardrails: monitor.guardrails.guardrails, + mocks: monitor.inspection.mockRules, + rewrites: monitor.inspection.rewriteRules, + transforms: monitor.inspection.responseTransformRules, + extensionRunning: monitor.canBlock, canBlock: monitor.canBlock, + inspectionEnabled: monitor.inspection.enabled, + inspectionRunning: monitor.inspection.running, + rulesPaused: store.isPaused, now: now) + } + private var subtitle: String { if store.isPaused, let until = store.pausedUntil { return L("Paused until %@", until.formatted(date: .omitted, time: .shortened)) } - let live = store.liveRules.count - guard !store.rules.isEmpty else { return "" } - return live == store.rules.count - ? L("%lld in force", live) : L("%1$lld of %2$lld in force", live, store.rules.count) + let effective = inventoryItems.filter { $0.effect.isEffective }.count + guard !inventoryItems.isEmpty else { return "" } + return effective == 0 + ? L("%lld policies · none currently effective", inventoryItems.count) + : L("%1$lld policies · %2$lld currently effective", inventoryItems.count, effective) } // MARK: Toolbar @ToolbarContentBuilder private var toolbar: some ToolbarContent { - // Nothing to switch between until there is something in one of them. - if !store.rules.isEmpty || !store.events.isEmpty || monitor.rulePacks.hasPackNavigation { - ToolbarItem(placement: .principal) { - Picker("", selection: $nav.rulesTab) { - ForEach(RulesTab.allCases) { Text($0.title).tag($0) } - } - .pickerStyle(.segmented).labelsHidden().frame(width: 310) + ToolbarItem(placement: .principal) { + Picker("", selection: $nav.rulesTab) { + ForEach(RulesTab.allCases) { Text($0.title).tag($0) } } + .pickerStyle(.segmented).labelsHidden().frame(width: 220) } ToolbarItemGroup { pauseControl @@ -181,26 +716,275 @@ struct RulesView: View { store.rules.filter { !($0.enabled && $0.isUsable && $0.schedule.isActive(at: now, session: RuleStore.session)) } } - private var ruleList: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - if !resting.isEmpty && !inForce.isEmpty { heading(L("In force")) } - ForEach(inForce) { rule in row(rule) } - if !resting.isEmpty { - heading(L("Not in force")).padding(.top, inForce.isEmpty ? 0 : 12) - ForEach(resting) { rule in row(rule) } + private var inventory: some View { + ScrollViewReader { scroll in + ScrollView { + LazyVStack(alignment: .leading, spacing: 18) { + inventoryIntroduction + if inventoryItems.isEmpty { empty } + ForEach(PolicyInventoryKind.allCases, id: \.self) { kind in + let items = inventoryItems.filter { $0.kind == kind } + if !items.isEmpty { inventorySection(kind, items: items) } + } + } + .padding(Measure.gutter) + } + .onChange(of: focusPolicyID) { _, target in + guard let target else { return } + Motion.perform(Motion.control, reduceMotion: reduceMotion) { + scroll.scrollTo(target, anchor: .center) + } + focusPolicyID = nil + } + .onAppear { + guard let target = focusPolicyID else { return } + DispatchQueue.main.async { + scroll.scrollTo(target, anchor: .center) + focusPolicyID = nil } } - .padding(Measure.gutter) } .safeAreaInset(edge: .top, spacing: 0) { banner } } + private var inventoryIntroduction: some View { + VStack(alignment: .leading, spacing: 4) { + Text(L("Everything Flowlight can apply")).font(.headline) + Text(PolicyInventoryCapabilities.current.inspectionPolicies + ? L("Network decisions, agent limits, managed destination lists, and developer inspection policies live here. Each policy still opens in the editor that owns its behavior and consent.") + : L("Network decisions, agent limits, and managed destination lists live here. Each policy still opens in the editor that owns its behavior and consent.")) + .font(.callout).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + } + } + + private func inventorySection(_ kind: PolicyInventoryKind, items: [PolicyInventoryItem]) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 7) { + Image(systemName: kind.symbol).font(.caption).foregroundStyle(.tertiary) + Text(kind.sectionTitle).font(.caption.weight(.semibold)).foregroundStyle(.secondary) + Text("\(items.count)").font(.caption2.monospacedDigit()).foregroundStyle(.tertiary) + Spacer() + if kind == .managedPack { packCatalogControls } + } + .id(kind == .managedPack ? "managed-packs" : "section-\(kind.rawValue)") + if kind == .managedPack { packCatalogProvenance } + VStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + if index > 0 { Divider().padding(.leading, 40) } + inventoryRow(item).id(item.id) + } + } + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 10)) + } + } + + @ViewBuilder + private func inventoryRow(_ item: PolicyInventoryItem) -> some View { + if item.kind == .networkRule, + let rule = store.rules.first(where: { $0.id.uuidString.caseInsensitiveCompare(item.sourceID) == .orderedSame }) { + row(rule) + } else if item.kind == .managedPack, + let pack = monitor.rulePacks.definitions.first(where: { $0.id == item.sourceID }) { + packRow(pack) + } else if item.kind == .managedPack, + let configuration = monitor.rulePacks.unavailableReviewConfigurations.first(where: { $0.packID == item.sourceID }) { + unavailablePackRow(configuration) + } else { + Button { handle(item.destination) } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: item.kind.symbol) + .font(.caption.weight(.semibold)) + .foregroundStyle(item.isEnabled ? FL.accent : .secondary) + .frame(width: 26, height: 26) + .background((item.isEnabled ? FL.accent : Color.secondary).opacity(0.12), + in: RoundedRectangle(cornerRadius: 7)) + VStack(alignment: .leading, spacing: 2) { + Text(item.title).font(.callout.weight(.medium)).lineLimit(1) + Text(item.detail).font(.caption).foregroundStyle(.secondary).lineLimit(2) + if let reason = item.reason { + Text(reason).font(.caption2).foregroundStyle(.tertiary).lineLimit(2) + } + } + Spacer(minLength: 8) + Text(item.effect.title) + .font(.caption.weight(.medium)) + .foregroundStyle(policyStatusTint(item.effect)) + Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary).padding(.top, 3) + } + .padding(.horizontal, 10).padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + + private func policyStatusTint(_ effect: PolicyEffectState) -> AnyShapeStyle { + switch effect { + case .effective: return AnyShapeStyle(FL.good) + case .reportingOnly, .watchingOnly, .waiting, .needsReview: return AnyShapeStyle(FL.warning) + case .failed, .unavailable: return AnyShapeStyle(FL.critical) + case .off: return AnyShapeStyle(.tertiary) + } + } + + private func packRow(_ pack: RulePackDefinition) -> some View { + let configuration = monitor.rulePacks.configuration(for: pack.id) + let item = inventoryItems.first { $0.kind == .managedPack && $0.sourceID == pack.id } + return HStack(alignment: .top, spacing: 10) { + Image(systemName: "shippingbox") + .font(.caption.weight(.semibold)) + .foregroundStyle(configuration.enabled ? FL.critical : .secondary) + .frame(width: 26, height: 26) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 7)) + VStack(alignment: .leading, spacing: 2) { + Text(L(pack.manifest.titleKey)).font(.callout.weight(.medium)) + Text(L("%lld destinations · %@", pack.entries.count, + configuration.scope == .allApps ? L("every app") : L("%lld selected apps", configuration.apps.count))) + .font(.caption).foregroundStyle(.secondary) + if let reason = item?.reason { + Text(reason).font(.caption2).foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + } + Text(L("Official · pack v%@ · %@", pack.manifest.version, pack.manifest.license)) + .font(.caption2).foregroundStyle(.tertiary) + if let error = packSaveErrors[pack.id] { + Text(error).font(.caption).foregroundStyle(FL.critical) + } + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 6) { + if let item { + Text(item.effect.title).font(.caption.weight(.medium)) + .foregroundStyle(policyStatusTint(item.effect)) + } + HStack(spacing: 8) { + Toggle("", isOn: Binding(get: { configuration.enabled }, set: { enabled in + if enabled { + sheet = .pack(pack, .activate) + } else { + var changed = configuration + changed.enabled = false + packSaveErrors[pack.id] = nil + monitor.rulePacks.save(changed) { success in + if !success { packSaveErrors[pack.id] = monitor.rulePacks.failureMessage ?? L("Failed: %@", L("Save")) } + } + } + })) + .toggleStyle(.switch).controlSize(.mini).labelsHidden() + Button(L("Review…")) { sheet = .pack(pack, .review) }.controlSize(.small) + } + } + } + .padding(.horizontal, 10).padding(.vertical, 8) + } + + private func unavailablePackRow(_ configuration: RulePackConfiguration) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "shippingbox.and.arrow.backward") + .font(.caption.weight(.semibold)).foregroundStyle(FL.warning) + .frame(width: 26, height: 26).background(FL.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 7)) + VStack(alignment: .leading, spacing: 2) { + Text(configuration.packID).font(.callout.weight(.medium)) + Text(L("This previously enabled official pack is no longer in the catalog. Blocking stopped automatically.")) + .font(.caption).foregroundStyle(.secondary) + if let error = packSaveErrors[configuration.packID] { + Text(error).font(.caption).foregroundStyle(FL.critical) + } + } + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 6) { + Text(PolicyEffectState.unavailable.title).font(.caption.weight(.medium)) + .foregroundStyle(policyStatusTint(.unavailable)) + Button(L("Acknowledge")) { + packSaveErrors[configuration.packID] = nil + monitor.rulePacks.acknowledgeUnavailableReview(configuration.packID) { success in + if !success { + packSaveErrors[configuration.packID] = monitor.rulePacks.failureMessage ?? L("Failed: %@", L("Save")) + } + } + } + .controlSize(.small) + } + } + .padding(.horizontal, 10).padding(.vertical, 8) + } + + private var packCatalogProvenance: some View { + DisclosureGroup { + VStack(alignment: .leading, spacing: 4) { + LabeledContent(L("Source")) { + Text(RulePackCatalogVerifier.sourceURL).font(.caption2.monospaced()) + .foregroundStyle(.secondary).textSelection(.enabled) + } + if let receipt = monitor.rulePackCatalog.receipt { + LabeledContent(L("Publisher")) { + Text(L("Flowlight · signer %@", receipt.signerKeyID)) + .font(.caption2.monospaced()).foregroundStyle(.secondary).textSelection(.enabled) + } + LabeledContent(L("Catalog")) { + Text(L("Sequence %1$lld · digest %2$@", receipt.sequence, String(receipt.digest.prefix(12)))) + .font(.caption2.monospaced()).foregroundStyle(.secondary).textSelection(.enabled) + } + } + if let success = monitor.rulePackCatalog.catalogState.update.lastSuccess { + LabeledContent(L("Last verified")) { + Text(success.formatted(date: .abbreviated, time: .shortened)) + .font(.caption2).foregroundStyle(.secondary) + } + } + if monitor.rulePackCatalog.automaticRefresh, + let next = monitor.rulePackCatalog.catalogState.update.nextEligibleAttempt { + LabeledContent(L("Next check")) { + Text(next.formatted(date: .abbreviated, time: .shortened)) + .font(.caption2).foregroundStyle(.secondary) + } + } + } + .padding(.top, 5) + } label: { + Label(L("Official catalog details"), systemImage: "checkmark.seal") + .font(.caption).foregroundStyle(.secondary) + } + .padding(.horizontal, 10).padding(.vertical, 7) + .background(.quaternary.opacity(0.22), in: RoundedRectangle(cornerRadius: 8)) + } + + private var packCatalogControls: some View { + HStack(spacing: 8) { + Text(packCatalogStatus).font(.caption2).foregroundStyle(.tertiary) + Menu { + Toggle(L("Automatic updates"), isOn: Binding( + get: { monitor.rulePackCatalog.automaticRefresh }, + set: { enabled in Task { await monitor.rulePackCatalog.setAutomaticRefresh(enabled) } } + )) + Button(L("Check Now")) { Task { await monitor.rulePackCatalog.checkNow() } } + .disabled(monitor.rulePackCatalog.status == .checking) + } label: { + Image(systemName: "arrow.triangle.2.circlepath") + } + .menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() + .help(L("Official pack updates")) + } + } + + private var packCatalogStatus: String { + switch monitor.rulePackCatalog.status { + case .idle: return L("Not checked") + case .checking: return L("Checking…") + case .current: return L("Verified") + case .stale: return L("Stale") + case .recovery: return L("Recovery required") + case .failed: return L("Check failed") + } + } + private func row(_ rule: Rule) -> some View { - RuleRow(rule: rule, store: store, now: now, - extensionRunning: monitor.mode == .networkExtension, - inspecting: monitor.inspection.enabled, - events: store.events(for: rule).count) { + let policy = inventoryItems.first { $0.kind == .networkRule && $0.sourceID == rule.id.uuidString.lowercased() } + return RuleRow(rule: rule, store: store, now: now, + extensionRunning: monitor.canBlock, + inspecting: monitor.inspection.enabled && monitor.inspection.running, + events: store.events(for: rule).count, + effect: policy?.effect ?? .off, effectReason: policy?.reason) { sheet = .editor(rule, isNew: false) } // A rule that has just been written, or has just crossed between in force and not, is a change of state @@ -258,11 +1042,13 @@ struct RulesView: View { private var activityFeed: some View { Group { - if store.events.isEmpty && monitor.rulePacks.events.isEmpty { + if policyActivity.isEmpty { ContentUnavailableView { Label(L("Nothing yet"), systemImage: "list.bullet.rectangle") } description: { - Text(L("Rule decisions and summarized pack blocks land here. Pack activity is grouped so a page full of ads does not become a page full of alerts.")) + Text(PolicyInventoryCapabilities.current.inspectionPolicies + ? L("Blocks, exceptions, allowlist violations, guardrails, mocks, and request or response changes appear here without changing their original records.") + : L("Network blocks, exceptions, managed-pack matches, and allowlist violations appear here without changing their original records.")) } } else { feed @@ -270,71 +1056,142 @@ struct RulesView: View { } } - /// The decisions, as the rest of the app reads: a day at a time, newest first. A table would sort and resize, - /// neither of which anybody asked of a feed — what is asked is "what happened, and which rule did it". + /// The existing sources, adapted into one read-only chronology. The original records remain the source of truth; + /// this list only gives their effects a shared vocabulary. private var feed: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 18) { - if !monitor.rulePacks.events.isEmpty { - VStack(alignment: .leading, spacing: 6) { - heading(L("Managed packs")) - VStack(spacing: 0) { - ForEach(Array(monitor.rulePacks.events.enumerated()), id: \.element.id) { index, event in - if index > 0 { Divider().padding(.leading, 34) } - RulePackEventRow(event: event) - } - } - .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 10)) - } - } - ForEach(feedDays) { day in + ForEach(activityDays) { day in VStack(alignment: .leading, spacing: 6) { heading(day.title) VStack(spacing: 0) { ForEach(Array(day.events.enumerated()), id: \.element.id) { index, event in - if index > 0 { Divider().padding(.leading, 34) } - EventRow(event: event, rule: name(of: event)) { - if let rule = store.rules.first(where: { $0.id == event.ruleID }) { - sheet = .editor(rule, isNew: false) - } + if index > 0 { Divider().padding(.leading, 38) } + PolicyActivityRow(event: event) { + guard let policyID = event.policyID, + let item = inventoryItems.first(where: { $0.id == policyID }) else { return } + handle(item.destination) } } } .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 10)) } - .entrance() + } + if hasMorePolicyEffects { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .onAppear { Task { await loadMorePolicyEffects() } } } } .padding(Measure.gutter) } } - private struct FeedDay: Identifiable { + private struct ActivityDay: Identifiable { let id: Date let title: String - let events: [RuleEventRecord] + let events: [PolicyActivityItem] + } + + private var policyActivity: [PolicyActivityItem] { + PolicyActivityBuilder.build(ruleEvents: store.events, packEvents: monitor.rulePacks.events, + alerts: alerts, exchanges: exchanges, rules: store.rules, + guardrails: monitor.guardrails.guardrails, packs: monitor.rulePacks.definitions, + mocks: monitor.inspection.mockRules, rewrites: monitor.inspection.rewriteRules, + transforms: monitor.inspection.responseTransformRules, + policies: Array(allowlists.values)) } - private var feedDays: [FeedDay] { + private var activityDays: [ActivityDay] { let calendar = Calendar.current - let byDay = Dictionary(grouping: store.events) { calendar.startOfDay(for: $0.timestamp) } + let byDay = Dictionary(grouping: policyActivity) { calendar.startOfDay(for: $0.timestamp) } return byDay.keys.sorted(by: >).map { day in let title = calendar.isDateInToday(day) ? L("Today") : calendar.isDateInYesterday(day) ? L("Yesterday") : day.formatted(date: .abbreviated, time: .omitted) - return FeedDay(id: day, title: title, - events: (byDay[day] ?? []).sorted { $0.timestamp > $1.timestamp }) + return ActivityDay(id: day, title: title, events: byDay[day] ?? []) + } + } +} + +private struct GlobalGuardrailsSheet: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var store: GuardrailStore + let focus: UUID? + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(L("Agent guardrails")).font(.title2.bold()) + Text(L("Every agent")).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button(L("Done")) { dismiss() }.keyboardShortcut(.defaultAction) + } + GuardrailsPane(store: store, agentKey: "", agentName: L("Every agent"), declared: [], servers: [], + focus: focus) + } + .padding(20) + .frame(width: 680, height: 640) + } +} + +private struct PolicyActivityRow: View { + let event: PolicyActivityItem + let showPolicy: () -> Void + + var body: some View { + Button(action: showPolicy) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbol) + .font(.caption).foregroundStyle(tint).frame(width: 18).padding(.top, 2) + VStack(alignment: .leading, spacing: 2) { + Text(event.title).font(.callout.weight(.medium)).lineLimit(1) + Text(event.detail).font(.caption).foregroundStyle(.secondary).lineLimit(2) + Text(effectTitle).font(.caption2).foregroundStyle(.tertiary) + } + Spacer(minLength: 8) + Text(event.timestamp.formatted(date: .omitted, time: .standard)) + .font(.caption.monospacedDigit()).foregroundStyle(.secondary) + } + .padding(.horizontal, 10).padding(.vertical, 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(event.policyID == nil) + } + + private var effectTitle: String { + switch event.effect { + case .blocked: return L("Blocked connection or request") + case .allowed: return L("Allowed by an exception") + case .packBlocked: return L("Blocked by a managed pack") + case .allowlistViolation: return L("Outside an agent allowlist") + case .guardrailApplied: return L("Capability removed by a guardrail") + case .mocked: return L("Answered by a mock") + case .requestRewritten: return L("Request changed before sending") + case .responseTransformed: return L("Response changed before delivery") } } - /// A decision can come from a network rule or from a guardrail — the same question asked about a destination - /// or about a tool — so the feed looks in both lists before giving up on it. - private func name(of event: RuleEventRecord) -> String { - if let rule = store.rules.first(where: { $0.id == event.ruleID }) { return RuleWords.title(rule) } - if let guardrail = monitor.guardrails.guardrails.first(where: { $0.id == event.ruleID }) { - return guardrail.localizedTitle + private var symbol: String { + switch event.effect { + case .allowed: return "checkmark.shield.fill" + case .mocked: return "wand.and.stars" + case .requestRewritten: return "slider.horizontal.3" + case .responseTransformed: return "arrow.left.arrow.right" + case .guardrailApplied: return "wrench.and.screwdriver.fill" + case .allowlistViolation: return "checklist.unchecked" + case .blocked, .packBlocked: return "hand.raised.fill" } - return L("A rule since deleted") + } + + private var tint: Color { + event.effect == .allowed ? FL.good + : (event.effect == .mocked || event.effect == .requestRewritten || event.effect == .responseTransformed + ? FL.tool : FL.critical) } } @@ -350,6 +1207,8 @@ private struct RuleRow: View { let extensionRunning: Bool let inspecting: Bool let events: Int + let effect: PolicyEffectState + let effectReason: String? let edit: () -> Void private var limitation: String? { @@ -380,6 +1239,8 @@ private struct RuleRow: View { meta } Spacer(minLength: 8) + Text(effect.title).font(.caption.weight(.medium)).foregroundStyle(effectTint) + .padding(.top, 3) Toggle("", isOn: Binding(get: { rule.enabled }, set: { store.setEnabled(rule, $0) })) .toggleStyle(.switch).controlSize(.mini).labelsHidden() .accessibilityLabel(L("Enable %@", RuleWords.title(rule))) @@ -399,6 +1260,8 @@ private struct RuleRow: View { } if let limitation { note(limitation, icon: "eye", tint: FL.warning) + } else if let effectReason, rule.isUsable { + note(effectReason, icon: effect == .off ? "power" : "clock", tint: .secondary) } if !rule.isUsable { note(L("Used up — a one-off allowance, already spent."), icon: "checkmark.circle", tint: .secondary) @@ -417,6 +1280,15 @@ private struct RuleRow: View { } } + private var effectTint: AnyShapeStyle { + switch effect { + case .effective: return AnyShapeStyle(FL.good) + case .reportingOnly, .watchingOnly, .waiting, .needsReview: return AnyShapeStyle(FL.warning) + case .failed, .unavailable: return AnyShapeStyle(FL.critical) + case .off: return AnyShapeStyle(.tertiary) + } + } + /// The verb, as a symbol. A rule that is only being watched says so here as well as in the strip below, because /// this is the mark the eye lands on first and a red hand over a rule that refuses nothing would be a lie. private var glyph: some View { @@ -946,7 +1818,7 @@ enum RuleWords { switch rule.engine { case .flow: return L("Watching only — the Network Extension refuses connections, and it isn't running.") - case .request: + case .request, .guardrail: return L("Watching only — a path can only be matched by HTTPS inspection, and it is off.") } } diff --git a/Flowlight/UI/SettingsView.swift b/Flowlight/UI/SettingsView.swift index d5fda6e2..132fa34a 100644 --- a/Flowlight/UI/SettingsView.swift +++ b/Flowlight/UI/SettingsView.swift @@ -42,11 +42,6 @@ struct SettingsView: View { DataPrivacySettingsTab() .tabItem { Label(L("Data & Privacy"), systemImage: "hand.raised.square") } - #if !APP_STORE - PluginSettingsView() - .tabItem { Label(L("Plugins"), systemImage: "puzzlepiece.extension") } - #endif - ExportSettingsTab() .tabItem { Label(L("Export"), systemImage: "arrow.up.forward.square") } } @@ -232,6 +227,7 @@ private struct NotificationSettingsRow: View { } struct MenuBarContent: View { + @ObservedObject var liveState: TrafficLiveState @AppStorage(AnomalySettings.Keys.backgroundOnly) private var backgroundOnly = false @EnvironmentObject var monitor: TrafficMonitor @EnvironmentObject var extensionManager: ExtensionManager @@ -253,10 +249,10 @@ struct MenuBarContent: View { .help(L("Open the filter update notice in Flowlight")) Divider() } - Text("↓ \(ByteFormat.rate(monitor.currentIn)) ↑ \(ByteFormat.rate(monitor.currentOut))") + Text("↓ \(ByteFormat.rate(liveState.currentIn)) ↑ \(ByteFormat.rate(liveState.currentOut))") if focus.isActive { Text(L("Focus: %@", focus.summary)) } Divider() - ForEach(monitor.talkers.prefix(5)) { t in + ForEach(liveState.talkers.prefix(5)) { t in Text("\(t.name): ↓ \(ByteFormat.rate(t.rateIn)) ↑ \(ByteFormat.rate(t.rateOut))") } if monitor.unacknowledgedAlerts > 0 { diff --git a/Flowlight/UI/Visualization/TrafficGraphLayout.swift b/Flowlight/UI/Visualization/TrafficGraphLayout.swift index 97c1c1c7..2245f9ac 100644 --- a/Flowlight/UI/Visualization/TrafficGraphLayout.swift +++ b/Flowlight/UI/Visualization/TrafficGraphLayout.swift @@ -821,8 +821,17 @@ enum TrafficGraphLayout { var previous = curve.start for step in 1...160 { let current = curve.point(at: CGFloat(step) / 160) - for obstacle in relevant where segment(previous, current, intersects: obstacle) { - return true + let minimumX = min(previous.x, current.x) + let maximumX = max(previous.x, current.x) + let minimumY = min(previous.y, current.y) + let maximumY = max(previous.y, current.y) + for obstacle in relevant { + // The curve-wide bounds above are intentionally conservative. Reject cards that cannot overlap this + // individual segment before running the more expensive exact clipping test; boundary contact remains + // inclusive, matching `segment(_:_:intersects:)`. + guard maximumX >= obstacle.minX, minimumX <= obstacle.maxX, + maximumY >= obstacle.minY, minimumY <= obstacle.maxY else { continue } + if segment(previous, current, intersects: obstacle) { return true } } previous = current } @@ -1199,6 +1208,10 @@ enum TrafficGraphLayout { frames: inout [TrafficGraphNodeID: CGRect], placementObserver: (@Sendable () -> Void)? ) -> Bool { + // Keep one ordered collision list for the whole placement pass. Rebuilding Array(frames.values) for every + // node turns a large graph into hundreds of successively larger allocations even though only one frame was + // added since the previous iteration. + var placedFrames = Array(frames.values) for lane in activeLanes { if Task.isCancelled { return false } let nodes = ordered[lane] ?? [] @@ -1216,14 +1229,16 @@ enum TrafficGraphLayout { if Task.isCancelled { return false } let preferredY = startY + size.height / 2 + CGFloat(index) * step let preferred = CGPoint(x: column, y: preferredY) - frames[node.id] = place( + let frame = place( size: size, near: preferred, laneX: column, within: insetBounds, - avoiding: Array(frames.values), + avoiding: placedFrames, gap: gap ) + frames[node.id] = frame + placedFrames.append(frame) placementObserver?() } } diff --git a/FlowlightExtension/BlockEnforcer.swift b/FlowlightExtension/BlockEnforcer.swift index 2c047b35..010bead7 100644 --- a/FlowlightExtension/BlockEnforcer.swift +++ b/FlowlightExtension/BlockEnforcer.swift @@ -255,7 +255,7 @@ final class BlockEnforcer: @unchecked Sendable { private func policy(for key: String) -> AgentPolicy? { lock.lock(); defer { lock.unlock() } - return policies[key] + return AgentPolicy.resolve(policies.values, identities: [key]) } // MARK: Agent identity @@ -289,10 +289,7 @@ final class BlockEnforcer: @unchecked Sendable { private func match(_ identity: (bundleID: String, name: String)) -> AgentPolicy? { lock.lock(); defer { lock.unlock() } - if let hit = policies[identity.bundleID] { return hit } - if let hit = policies[identity.name] { return hit } - let name = identity.name.lowercased() - return policies.values.first { $0.agentID.lowercased() == name } + return AgentPolicy.resolve(policies.values, identities: [identity.bundleID, identity.name]) } /// Parent pid from the kernel process table. diff --git a/FlowlightTests/AgentInventoryTests.swift b/FlowlightTests/AgentInventoryTests.swift new file mode 100644 index 00000000..8af20175 --- /dev/null +++ b/FlowlightTests/AgentInventoryTests.swift @@ -0,0 +1,402 @@ +import XCTest +@testable import Flowlight + +final class AgentInventoryTests: XCTestCase { + private let healthyInspection = AgentInspectionDiagnostics( + available: true, enabled: true, running: true, certificateTrusted: true, setupFailed: false + ) + + private func summary( + id: String, + name: String, + known: Bool = true, + path: String = "", + provider: String = "Anthropic", + otherHost: String? = nil + ) -> AgentSummary { + let ai = FlowCounters(bytesIn: 400, bytesOut: 100, flows: 1) + var destinations = [ + AgentDestination( + label: "api.provider.test", ip: "203.0.113.10", provider: provider, + protocols: ["https"], ports: "443", counters: ai + ), + ] + var egress = FlowCounters() + if let otherHost { + egress = FlowCounters(bytesIn: 20, bytesOut: 10, flows: 1) + destinations.append(AgentDestination( + label: otherHost, ip: "203.0.113.11", provider: nil, + protocols: ["https"], ports: "443", counters: egress + )) + } + return AgentSummary( + bundleID: id, + name: name, + vendor: known ? "Vendor" : nil, + isKnown: known, + appPath: path, + ai: ai, + egress: egress, + providers: [(provider, ai)], + destinations: destinations, + alerts: 0 + ) + } + + private func alert( + bundleID: String, + appName: String, + state: AlertTriageState = .new, + detail: String = "Review this activity" + ) -> AlertRecord { + AlertRecord( + id: 1, + timestamp: Date(timeIntervalSince1970: 1_000), + kind: "agent-test", + bundleID: bundleID, + appName: appName, + detail: detail, + severity: 2, + state: state + ) + } + + func testInstalledRecognizedGUIAgentIsInactiveWithoutTraffic() { + let cursor = InstalledApps.App( + name: "Cursor", bundleID: "com.todesktop.230313mzl4w4u92", path: "/Applications/Cursor.app" + ) + + let item = AgentInventoryBuilder.build(AgentInventoryInput(installedApps: [cursor])).first + + XCTAssertEqual(item?.name, "Cursor") + XCTAssertEqual(item?.health, .inactive) + XCTAssertEqual(item?.reasons, ["Installed on this Mac, with no traffic in the selected window."]) + } + + func testUppercaseStableIdentifierStillResolvesKnownAgent() throws { + let cursor = InstalledApps.App( + name: "Cursor", bundleID: "COM.TODESKTOP.230313MZL4W4U92", path: "/Applications/Cursor.app" + ) + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput(installedApps: [cursor])).first) + XCTAssertEqual(item.name, "Cursor") + XCTAssertEqual(item.health, .inactive) + } + + func testActiveInstalledAgentMergesWithTrafficAndCanBeHealthy() throws { + let cursor = InstalledApps.App( + name: "Cursor", bundleID: "com.todesktop.230313mzl4w4u92", path: "/Applications/Cursor.app" + ) + let traffic = summary(id: cursor.bundleID, name: "Cursor", path: cursor.path, provider: "Cursor") + + let items = AgentInventoryBuilder.build(AgentInventoryInput( + installedApps: [cursor], + traffic: [traffic], + routes: [cursor.bundleID: .observed], + inspection: healthyInspection + )) + + let item = try XCTUnwrap(items.first) + XCTAssertEqual(items.count, 1) + XCTAssertTrue(item.installed) + XCTAssertTrue(item.observed) + XCTAssertEqual(item.health, .healthy) + } + + func testObservedKnownCLIAgentAppearsWithoutApplicationBundle() throws { + let claude = summary(id: "claude", name: "Claude Code") + + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [claude], + routes: ["claude": .observed], + inspection: healthyInspection + )).first) + + XCTAssertEqual(item.id, "claude") + XCTAssertEqual(item.name, "Claude Code") + XCTAssertFalse(item.installed) + XCTAssertTrue(item.observed) + XCTAssertEqual(item.health, .healthy) + } + + func testKnownAliasesMergeAllTrafficEvidence() throws { + let primary = summary(id: "claude", name: "Claude Code", provider: "Anthropic") + let alias = summary(id: "claude-code", name: "Claude Code", provider: "OpenAI", + otherHost: "uploads.example") + + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [alias, primary], + routes: ["claude-code": .observed], + inspection: healthyInspection + )).first) + + XCTAssertEqual(item.id, "claude") + XCTAssertEqual(item.identities.first, "claude") + XCTAssertTrue(item.identities.contains("claude-code")) + XCTAssertTrue(item.identities.contains("Claude Code")) + XCTAssertEqual(Set(item.trafficIdentities), ["claude", "claude-code"]) + XCTAssertFalse(item.trafficIdentities.contains("Claude Code"), + "display labels must not become exact traffic filters") + XCTAssertEqual(item.summary?.total.total, primary.total.total + alias.total.total) + XCTAssertEqual(Set(item.summary?.providers.map(\.name) ?? []), ["Anthropic", "OpenAI"]) + XCTAssertEqual(Set(item.summary?.destinations.map(\.label) ?? []), + ["api.provider.test", "uploads.example"]) + XCTAssertEqual(item.route, .observed) + } + + func testPolicySelectionUsesStableIdentityPriority() throws { + let primary = summary(id: "claude", name: "Claude Code", provider: "Anthropic") + let alias = summary(id: "claude-code", name: "Claude Code", provider: "OpenAI", + otherHost: "uploads.example") + let canonical = AgentPolicy(agentID: "claude", enabled: true, + patterns: ["uploads.example"], enforce: true) + let secondary = AgentPolicy(agentID: "claude-code", enabled: true, patterns: [], enforce: false) + var forward: [String: AgentPolicy] = [:] + forward[canonical.agentID] = canonical + forward[secondary.agentID] = secondary + var reverse: [String: AgentPolicy] = [:] + reverse[secondary.agentID] = secondary + reverse[canonical.agentID] = canonical + + let first = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [primary, alias], policies: forward + )).first) + let second = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [alias, primary], policies: reverse + )).first) + + XCTAssertEqual(first.id, "claude") + XCTAssertEqual(first.policy, second.policy) + XCTAssertTrue(first.policy.enabled) + XCTAssertTrue(first.policy.enforcing) + XCTAssertEqual(first.policy.uncoveredDestinations, 0) + } + + func testConfiguredKnownCLIAgentIsNotObserved() throws { + let workspace = AgentWorkspace(agent: "codex", root: "~/.codex", isProject: false) + + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + workspaces: [workspace], + inspection: healthyInspection + )).first) + + XCTAssertEqual(item.name, "Codex") + XCTAssertTrue(item.configured) + XCTAssertEqual(item.health, .notObserved) + XCTAssertEqual(item.reasons.first, + "Configuration was found, but no traffic was observed in the selected window.") + } + + func testAutoDiscoversNonBrowserProviderClient() throws { + let script = summary(id: "python3", name: "Research worker", known: false, provider: "OpenAI") + + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [script], inspection: .unavailable + )).first) + + XCTAssertEqual(item.id, "python3") + XCTAssertTrue(item.autoDiscovered) + XCTAssertEqual(item.health, .limitedVisibility) + } + + func testSavedPolicyKeepsQuietAgentVisibleWithStableCatalogID() throws { + let policy = AgentPolicy(agentID: "claude-code", patterns: ["github.com"]) + let quiet = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + policies: [policy.agentID: policy] + )).first) + XCTAssertEqual(quiet.id, "claude") + XCTAssertEqual(quiet.name, "Claude Code") + XCTAssertTrue(quiet.policy.enabled) + + let observed = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [summary(id: "claude-code", name: "Claude Code")], + policies: [policy.agentID: policy] + )).first) + XCTAssertEqual(observed.id, quiet.id, "catalog row identity must not change with the observed alias") + } + + func testAgentScopedGuardrailKeepsQuietAgentVisibleForDeepLinks() throws { + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + guardrailAgentIDs: ["quiet-worker"] + )).first) + + XCTAssertEqual(item.id, "quiet-worker") + XCTAssertTrue(item.identities.contains("quiet-worker")) + XCTAssertEqual(item.trafficIdentities, ["quiet-worker"]) + XCTAssertFalse(item.observed) + } + + func testUnknownObservedAgentAndSavedPolicyMergeIntoOneRow() throws { + let policy = AgentPolicy(agentID: "custom-worker", enabled: true, patterns: ["api.example"]) + let items = AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [summary(id: "custom-worker", name: "Custom worker", known: false)], + policies: [policy.agentID: policy] + )) + + let item = try XCTUnwrap(items.first) + XCTAssertEqual(items.count, 1) + XCTAssertEqual(item.id, "custom-worker") + XCTAssertTrue(item.policy.enabled) + } + + func testBrowserProviderTrafficIsExcluded() { + let browser = summary( + id: "com.google.Chrome", name: "Chrome", known: false, provider: "OpenAI" + ) + let installed = InstalledApps.App( + name: "Safari", bundleID: "com.apple.Safari", path: "/System/Applications/Safari.app" + ) + + XCTAssertTrue(AgentInventoryBuilder.build(AgentInventoryInput( + installedApps: [installed], traffic: [browser] + )).isEmpty) + } + + func testStaleAndFailedRoutesNeedAttention() throws { + let claude = summary(id: "claude", name: "Claude Code") + let stale = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [claude], routes: ["claude": .stale], inspection: healthyInspection + )).first) + let failed = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [claude], routes: ["Claude Code": .failed], inspection: healthyInspection + )).first) + + XCTAssertEqual(stale.health, .attention) + XCTAssertTrue(stale.reasons.contains("The saved Flowlight route was not observed in this window.")) + XCTAssertEqual(failed.health, .attention) + XCTAssertTrue(failed.reasons.contains("Flowlight could not establish the requested route.")) + } + + func testMissingCertificateTrustNeedsSetup() throws { + let inspection = AgentInspectionDiagnostics( + available: true, enabled: true, running: true, certificateTrusted: false, setupFailed: false + ) + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [summary(id: "claude", name: "Claude Code")], + routes: ["claude": .observed], + inspection: inspection + )).first) + + XCTAssertEqual(item.health, .needsSetup) + XCTAssertTrue(item.reasons.contains("The HTTPS inspection certificate is not trusted.")) + } + + func testPolicyCoverageAndUnresolvedAlertsNeedAttention() throws { + let traffic = summary(id: "claude", name: "Claude Code", otherHost: "uploads.example") + let policy = AgentPolicy(agentID: "claude", enabled: true, patterns: ["github.com"], enforce: true) + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [traffic], + routes: ["claude": .observed], + inspection: healthyInspection, + policies: ["claude": policy], + alerts: [ + alert(bundleID: "claude", appName: "Claude Code"), + alert(bundleID: "claude", appName: "Claude Code", state: .investigating), + alert(bundleID: "claude", appName: "Claude Code", state: .resolved), + ] + )).first) + + XCTAssertEqual(item.policy.uncoveredDestinations, 1) + XCTAssertEqual(item.unresolvedAlerts, 2, "new and investigating alerts remain open; resolved alerts do not") + XCTAssertEqual(item.health, .attention) + XCTAssertTrue(item.reasons.contains("One observed destination is outside its policy.")) + XCTAssertTrue(item.reasons.contains("2 unresolved alerts need review.")) + } + + func testGroupedAlertCountsMatchAliasesWithoutDoubleCounting() throws { + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [summary(id: "claude", name: "Claude Code")], + routes: ["claude": .observed], + inspection: healthyInspection, + openAlertCounts: [ + AgentOpenAlertCount(bundleID: "claude", appName: "Claude Code", count: 3), + ] + )).first) + + XCTAssertEqual(item.unresolvedAlerts, 3, + "one grouped row matching both process and display aliases must only be counted once") + } + + func testClaudeDesktopAndCodeKeepDistinctInventoryEvidence() throws { + let items = AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [ + summary(id: "com.anthropic.claudefordesktop", name: "Claude"), + summary(id: "claude", name: "Claude Code"), + ], + routes: ["Claude": .stale, "claude": .observed], + openAlertCounts: [ + AgentOpenAlertCount(bundleID: "com.anthropic.claudefordesktop", appName: "Claude", count: 2), + ] + )) + + let desktop = try XCTUnwrap(items.first { $0.name == "Claude" }) + let code = try XCTUnwrap(items.first { $0.name == "Claude Code" }) + XCTAssertEqual(desktop.unresolvedAlerts, 2) + XCTAssertEqual(desktop.route, .stale) + XCTAssertEqual(code.unresolvedAlerts, 0) + XCTAssertEqual(code.route, .observed) + XCTAssertNotEqual(AgentCatalog.identityComparisonKey("Claude"), + AgentCatalog.identityComparisonKey("claude")) + } + + func testDisabledProviderExemptionCountsProviderOutsidePolicy() throws { + let traffic = summary(id: "claude", name: "Claude Code") + let policy = AgentPolicy(agentID: "claude", enabled: true, allowAIProviders: false, + patterns: [], enforce: true) + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [traffic], routes: ["claude": .observed], inspection: healthyInspection, + policies: ["claude": policy] + )).first) + + XCTAssertEqual(item.policy.uncoveredDestinations, 1) + XCTAssertEqual(item.health, .attention) + } + + func testReasonsHaveStableEvidenceRouteInspectionPolicyAlertOrder() throws { + let traffic = summary(id: "claude", name: "Claude Code", otherHost: "uploads.example") + let policy = AgentPolicy(agentID: "claude", enabled: true, patterns: [], enforce: false) + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [traffic], + routes: ["claude": .stale], + inspection: healthyInspection, + policies: ["claude": policy], + alerts: [alert(bundleID: "claude", appName: "Claude Code")] + )).first) + + XCTAssertEqual(item.reasons, [ + "Traffic was observed in the selected window.", + "The saved Flowlight route was not observed in this window.", + "No inspected requests were observed in this window.", + "One observed destination is outside its policy.", + "One unresolved alert needs review.", + ]) + } + + func testDisplayTextRedactsPathsEndpointsSecretsAndAlertDetails() throws { + let secretPath = "/Users/alice/private/token-123/.claude" + let gateway = AgentGatewayConfig( + agent: "claude", + endpoint: AgentGatewayConfig.Endpoint(scheme: "https", host: "secret.internal", port: 9443), + source: secretPath + "/settings.json", + sourceKey: "env.ANTHROPIC_BASE_URL" + ) + let workspace = AgentWorkspace( + agent: "claude", root: secretPath, isProject: false, gateways: [gateway] + ) + let item = try XCTUnwrap(AgentInventoryBuilder.build(AgentInventoryInput( + traffic: [summary(id: "claude", name: "Claude Code", path: secretPath + "/claude")], + workspaces: [workspace], + routes: ["claude": .failed], + inspection: healthyInspection, + alerts: [alert( + bundleID: "claude", appName: "Claude Code", + detail: "Bearer sk-secret-value at \(secretPath) via https://secret.internal:9443" + )] + )).first) + + for secret in [secretPath, "token-123", "secret.internal", "9443", "sk-secret-value", "Bearer"] { + XCTAssertFalse(item.displayText.contains(secret), "display text leaked \(secret)") + } + XCTAssertTrue(item.displayText.contains("Attention")) + XCTAssertEqual(item.appPath, secretPath + "/claude", "the path remains available only for icon loading") + } +} diff --git a/FlowlightTests/AgentPolicyTests.swift b/FlowlightTests/AgentPolicyTests.swift index 79477db9..2657b9d4 100644 --- a/FlowlightTests/AgentPolicyTests.swift +++ b/FlowlightTests/AgentPolicyTests.swift @@ -22,6 +22,29 @@ final class AgentPolicyTests: XCTestCase { XCTAssertNil(AgentPolicy.normalize("")) } + func testCatalogAliasesSharePolicyRuleAndGuardrailIdentity() { + XCTAssertEqual(AgentCatalog.knownAgent(identity: "claude")?.name, "Claude Code") + XCTAssertEqual(AgentCatalog.knownAgent(identity: "claude-code")?.name, "Claude Code") + XCTAssertTrue(AgentCatalog.sameAgentIdentity("claude", "claude-code")) + XCTAssertTrue(AgentCatalog.sameAgentIdentity("Claude Code", "claude")) + XCTAssertFalse(AgentCatalog.sameAgentIdentity("Claude", "claude"), + "the desktop display label is not the Claude Code executable") + XCTAssertTrue(AgentCatalog.sameAgentIdentity("Claude", "com.anthropic.claudefordesktop")) + XCTAssertEqual(AgentCatalog.knownAgent(identity: "Claude")?.name, "Claude") + XCTAssertFalse(AgentCatalog.sameAgentIdentity("claude", "codex")) + XCTAssertTrue(AgentCatalog.sameAgentIdentity("MyAgent", "myagent"), + "uncatalogued identities remain case-insensitive") + XCTAssertTrue(Rule.appMatches("claude", bundleID: "claude-code", agentKey: "claude-code")) + XCTAssertTrue(Guardrail(agent: "claude", tool: "bash").refuses(agent: "claude-code", server: nil, tool: "bash")) + XCTAssertTrue(Guardrail(agent: "MyAgent", resource: "file://private/*") + .refuses(agent: "myagent", resource: "file://private/token")) + + let desktop = AgentPolicy(agentID: "Claude", patterns: ["desktop.example"]) + let code = AgentPolicy(agentID: "claude", patterns: ["code.example"]) + XCTAssertEqual(AgentPolicy.resolve([desktop, code], identities: ["Claude"])?.patterns, ["desktop.example"]) + XCTAssertEqual(AgentPolicy.resolve([desktop, code], identities: ["claude"])?.patterns, ["code.example"]) + } + func testAllowsProvidersAndLocalNetwork() { var policy = AgentPolicy(agentID: "claude", patterns: ["github.com", "npmjs.org"]) XCTAssertTrue(policy.allows(host: "api.anthropic.com", ip: "160.79.104.10", isAIProvider: true)) @@ -57,9 +80,14 @@ final class AgentPolicyTests: XCTestCase { XCTAssertEqual(violations.count, 1) XCTAssertEqual(violations.first?.bundleID, "claude") XCTAssertTrue(violations.first?.detail.contains("Claude Code › curl contacted paste.example") == true, violations.first?.detail ?? "") - // Agents without a policy aren't affected. - try engine.observe([TrafficBatch(timestamp: 101, records: [TrafficRecord(key: key("codex", "paste.example"), counters: c)])]) - XCTAssertEqual(fired.filter { $0.kind == AnomalyEngine.Kind.allowlistViolation.rawValue }.count, 1) + // A catalog alias is the same agent policy, while an unrelated agent remains unaffected. + try engine.observe([TrafficBatch(timestamp: 101, records: [ + TrafficRecord(key: key("claude-code", "upload.example"), counters: c), + TrafficRecord(key: key("codex", "paste.example"), counters: c), + ])]) + let allViolations = fired.filter { $0.kind == AnomalyEngine.Kind.allowlistViolation.rawValue } + XCTAssertEqual(allViolations.count, 2) + XCTAssertTrue(allViolations.contains { $0.bundleID == "claude-code" }) } func testPoliciesPersist() throws { @@ -71,4 +99,40 @@ final class AgentPolicyTests: XCTestCase { try db.savePolicy(updated) XCTAssertEqual(try db.loadPolicies(), ["claude": updated]) } + + func testSavingCatalogAliasReplacesObsoleteAliasPolicy() throws { + let db = try TrafficDatabase(url: FileManager.default.temporaryDirectory.appendingPathComponent("policy-alias-\(UUID()).sqlite")) + try db.savePolicy(AgentPolicy(agentID: "claude", patterns: ["old.example"])) + let replacement = AgentPolicy(agentID: "claude-code", patterns: ["new.example"], enforce: true) + try db.savePolicy(replacement) + + XCTAssertEqual(try db.loadPolicies(), ["claude-code": replacement]) + XCTAssertEqual(try db.raw("SELECT agent_id FROM agent_policies", map: { $0.text(0) }), ["claude-code"]) + } + + func testSavingCustomCaseVariantReplacesThePreviousPolicyRow() throws { + let db = try TrafficDatabase( + url: FileManager.default.temporaryDirectory + .appendingPathComponent("policy-case-\(UUID()).sqlite") + ) + try db.savePolicy(AgentPolicy(agentID: "MyAgent", patterns: ["old.example"])) + let replacement = AgentPolicy(agentID: "myagent", patterns: ["new.example"], enforce: true) + try db.savePolicy(replacement) + + XCTAssertEqual(try db.loadPolicies(), ["myagent": replacement]) + XCTAssertEqual( + try db.raw("SELECT agent_id FROM agent_policies", map: { $0.text(0) }), + ["myagent"] + ) + } + + func testPolicyResolutionIsStableForLegacyAliasDuplicates() { + let policies = [ + AgentPolicy(agentID: "claude-code", patterns: ["second.example"]), + AgentPolicy(agentID: "claude", patterns: ["first.example"]), + ] + XCTAssertEqual(AgentPolicy.resolve(policies, identities: ["Claude Code"])?.agentID, "claude") + XCTAssertEqual(AgentPolicy.resolve(policies.reversed(), identities: ["Claude Code"])?.agentID, "claude") + XCTAssertEqual(AgentPolicy.resolve(policies, identities: ["claude-code", "claude"])?.agentID, "claude-code") + } } diff --git a/FlowlightTests/AgentTests.swift b/FlowlightTests/AgentTests.swift index a1ce5e8f..ddde8440 100644 --- a/FlowlightTests/AgentTests.swift +++ b/FlowlightTests/AgentTests.swift @@ -58,7 +58,7 @@ final class AgentTests: XCTestCase { XCTAssertFalse(python.isKnown, "discovered through its LLM API calls") XCTAssertEqual(python.providers.first?.name, "OpenAI") XCTAssertTrue(python.otherDestinations.contains { $0.isUnnamed }) - XCTAssertGreaterThan(claude.riskScore, python.riskScore) + XCTAssertTrue(claude.sensitiveProtocols.contains("smtp-submission")) XCTAssertTrue(IPOwnerLookup.isLocalNetwork("192.168.1.1")) XCTAssertTrue(IPOwnerLookup.isLocalNetwork("fe80::1")) XCTAssertFalse(IPOwnerLookup.isLocalNetwork("203.0.113.9"), "reserved ranges still count as leaving the Mac") diff --git a/FlowlightTests/AlertTriageTests.swift b/FlowlightTests/AlertTriageTests.swift new file mode 100644 index 00000000..42189c96 --- /dev/null +++ b/FlowlightTests/AlertTriageTests.swift @@ -0,0 +1,240 @@ +import XCTest +@testable import Flowlight + +final class AlertTriageTests: XCTestCase { + private var databaseURL: URL! + private var db: TrafficDatabase! + + override func setUpWithError() throws { + databaseURL = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-alert-triage-\(UUID().uuidString).sqlite") + db = try TrafficDatabase(url: databaseURL) + } + + override func tearDownWithError() throws { + db = nil + for suffix in ["", "-wal", "-shm"] { + try? FileManager.default.removeItem(atPath: databaseURL.path + suffix) + } + } + + @discardableResult + private func add(_ app: String = "com.example.App", at: TimeInterval = 100, + allowPattern: String = "") throws -> AlertRecord { + try db.addAlert(kind: "New destination", bundleID: app, appName: app, detail: "detail", severity: 2, + at: Date(timeIntervalSince1970: at), allowPattern: allowPattern) + } + + func testRuleSelectionClearsWhenTheNewStatusHasNoMatchingKind() throws { + let alert = try add() + + XCTAssertEqual(AlertRuleSelection.retained("New destination", in: [alert]), "New destination") + XCTAssertEqual(AlertRuleSelection.retained("Another kind", in: [alert]), "") + XCTAssertEqual(AlertRuleSelection.retained("", in: [alert]), "") + } + + func testLegacyAcknowledgedMigrationPreservesMeaning() throws { + db = nil + for suffix in ["", "-wal", "-shm"] { + try? FileManager.default.removeItem(atPath: databaseURL.path + suffix) + } + var legacy: SQLiteConnection? = try SQLiteConnection(path: databaseURL.path) + try legacy?.execute(""" + CREATE TABLE alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, kind TEXT NOT NULL, + bundle_id TEXT NOT NULL, app_name TEXT NOT NULL, detail TEXT NOT NULL, + severity INTEGER NOT NULL, acknowledged INTEGER NOT NULL DEFAULT 0 + ); + INSERT INTO alerts (ts, kind, bundle_id, app_name, detail, severity, acknowledged) + VALUES (100, 'one', 'new.app', 'New', '', 1, 0); + INSERT INTO alerts (ts, kind, bundle_id, app_name, detail, severity, acknowledged) + VALUES (101, 'two', 'seen.app', 'Seen', '', 1, 1); + """) + legacy = nil + + db = try TrafficDatabase(url: databaseURL) + let migrated = try db.alerts() + XCTAssertEqual(Dictionary(uniqueKeysWithValues: migrated.map { ($0.bundleID, $0.state) }), + ["new.app": .new, "seen.app": .acknowledged]) + XCTAssertEqual(migrated.first { $0.bundleID == "new.app" }?.triageUpdatedAt, + Date(timeIntervalSince1970: 100)) + XCTAssertEqual(migrated.first { $0.bundleID == "seen.app" }?.triageUpdatedAt, + Date(timeIntervalSince1970: 101)) + XCTAssertEqual(Set(try db.raw("PRAGMA table_info(alerts)", map: { $0.text(1) })) + .intersection(["triage_state", "disposition", "triage_note", "triage_updated_at"]), + ["triage_state", "disposition", "triage_note", "triage_updated_at"]) + } + + func testNewAlertDefaultsSurviveReloadAndClearAllRemovesTriage() throws { + let alert = try add(allowPattern: "api.example") + XCTAssertEqual(alert.state, .new) + XCTAssertEqual(alert.disposition, .unset) + XCTAssertEqual(alert.note, "") + XCTAssertEqual(alert.triageUpdatedAt, alert.timestamp) + + db = nil + db = try TrafficDatabase(url: databaseURL) + let reloaded = try XCTUnwrap(db.alerts().first) + XCTAssertEqual(reloaded.state, .new) + XCTAssertEqual(reloaded.disposition, .unset) + XCTAssertEqual(reloaded.allowPattern, "api.example") + + try db.setAlertTriageState(.investigating, ids: [reloaded.id], at: Date(timeIntervalSince1970: 200)) + try db.setAlertDisposition(.truePositive, ids: [reloaded.id], at: Date(timeIntervalSince1970: 201)) + try db.setAlertNote("local note", id: reloaded.id, at: Date(timeIntervalSince1970: 202)) + db = nil + db = try TrafficDatabase(url: databaseURL) + let triaged = try XCTUnwrap(db.alerts().first) + XCTAssertEqual(triaged.state, .investigating) + XCTAssertEqual(triaged.disposition, .truePositive) + XCTAssertEqual(triaged.note, "local note") + XCTAssertEqual(triaged.triageUpdatedAt, Date(timeIntervalSince1970: 202)) + + try db.clearAll() + db = nil + db = try TrafficDatabase(url: databaseURL) + XCTAssertTrue(try db.alerts().isEmpty) + } + + func testTransitionsAreValidatedAndTerminalAlertsCanOnlyReopenForInvestigation() throws { + let alert = try add() + try db.setAlertTriageState(.acknowledged, ids: [alert.id]) + try db.setAlertTriageState(.investigating, ids: [alert.id]) + try db.setAlertTriageState(.resolved, ids: [alert.id]) + + XCTAssertThrowsError(try db.setAlertTriageState(.acknowledged, ids: [alert.id])) { error in + XCTAssertEqual(error as? AlertTriageError, + .invalidTransition(from: .resolved, to: .acknowledged)) + } + XCTAssertEqual(try db.alerts().first?.state, .resolved) + + try db.setAlertTriageState(.investigating, ids: [alert.id]) + XCTAssertEqual(try db.alerts().first?.state, .investigating) + } + + func testDispositionIsIndependentAndUpdatesTimestamp() throws { + let alert = try add() + let changed = Date(timeIntervalSince1970: 250) + try db.setAlertDisposition(.expectedBenign, ids: [alert.id], at: changed) + + let stored = try XCTUnwrap(db.alerts().first) + XCTAssertEqual(stored.state, .new) + XCTAssertEqual(stored.disposition, .expectedBenign) + XCTAssertEqual(stored.triageUpdatedAt, changed) + } + + func testNoteBoundAndTimestampAreEnforcedWithoutOverwritingStoredNote() throws { + let alert = try add() + let changed = Date(timeIntervalSince1970: 300) + let maximum = String(repeating: "x", count: AlertRecord.maximumNoteLength) + try db.setAlertNote(maximum, id: alert.id, at: changed) + XCTAssertEqual(try db.alerts().first?.note.count, AlertRecord.maximumNoteLength) + XCTAssertEqual(try db.alerts().first?.triageUpdatedAt, changed) + + XCTAssertThrowsError(try db.setAlertNote(maximum + "x", id: alert.id, + at: Date(timeIntervalSince1970: 400))) { error in + XCTAssertEqual(error as? AlertTriageError, + .noteTooLong(maximum: AlertRecord.maximumNoteLength)) + } + let unchanged = try XCTUnwrap(db.alerts().first) + XCTAssertEqual(unchanged.note, maximum) + XCTAssertEqual(unchanged.triageUpdatedAt, changed) + } + + func testBulkStateAndDispositionUpdatesAreAtomic() throws { + let first = try add("first.app") + let second = try add("second.app") + try db.setAlertTriageState(.resolved, ids: [second.id]) + + XCTAssertThrowsError(try db.setAlertTriageState(.acknowledged, ids: [first.id, second.id])) + let afterStateFailure = Dictionary(uniqueKeysWithValues: try db.alerts().map { ($0.id, $0.state) }) + XCTAssertEqual(afterStateFailure[first.id], .new) + XCTAssertEqual(afterStateFailure[second.id], .resolved) + + XCTAssertThrowsError(try db.setAlertDisposition(.falsePositive, ids: [first.id, 9_999_999])) + XCTAssertEqual(try db.alerts().first { $0.id == first.id }?.disposition, .unset) + } + + func testLargeBulkUpdatesStayAtomicBeyondSQLiteVariableLimits() throws { + var ids: [Int64] = [] + for index in 0..<1_200 { + ids.append(try add("bulk.app", at: TimeInterval(1_000 + index)).id) + } + + try db.setAlertTriageState(.acknowledged, ids: ids) + try db.setAlertDisposition(.truePositive, ids: ids) + let updated = try db.alerts(limit: 1_500) + XCTAssertEqual(updated.count, ids.count) + XCTAssertTrue(updated.allSatisfy { $0.state == .acknowledged && $0.disposition == .truePositive }) + } + + func testAcknowledgeAllNewHonorsFocusWithoutMaterializingEveryID() throws { + for index in 0..<1_200 { _ = try add("a.app", at: TimeInterval(2_000 + index)) } + _ = try add("b.app", at: 4_000) + + try db.acknowledgeAlerts(ids: nil, focus: FocusScope(bundleIDs: ["a.app"])) + + XCTAssertEqual(try db.newAlertCount(focus: FocusScope(bundleIDs: ["a.app"])), 0) + XCTAssertEqual(try db.newAlertCount(focus: FocusScope(bundleIDs: ["b.app"])), 1) + } + + func testKindFilterAppliesBeforeAlertLimit() throws { + _ = try db.addAlert(kind: AnomalyEngine.Kind.allowlistViolation.rawValue, + bundleID: "agent.app", appName: "Agent", detail: "older", severity: 2, + at: Date(timeIntervalSince1970: 100)) + _ = try db.addAlert(kind: "New destination", bundleID: "other.app", appName: "Other", + detail: "newer", severity: 1, at: Date(timeIntervalSince1970: 200)) + + let loaded = try db.alerts(limit: 1, kind: AnomalyEngine.Kind.allowlistViolation.rawValue) + + XCTAssertEqual(loaded.count, 1) + XCTAssertEqual(loaded.first?.bundleID, "agent.app") + } + + func testBadgeCountsOnlyNewAlertsWithinFocus() throws { + let newA = try add("a.app") + let acknowledgedA = try add("a.app") + let investigatingA = try add("a.app") + _ = try add("b.app") + try db.setAlertTriageState(.acknowledged, ids: [acknowledgedA.id]) + try db.setAlertTriageState(.investigating, ids: [investigatingA.id]) + + XCTAssertEqual(try db.newAlertCount(), 2) + XCTAssertEqual(try db.unacknowledgedAlertCount(), 2, "compatibility count must have new-state semantics") + XCTAssertEqual(try db.newAlertCount(focus: FocusScope(bundleIDs: ["a.app"])), 1) + XCTAssertEqual(try db.newAlertCount(focus: FocusScope(hosts: ["example.com"])), 2) + XCTAssertEqual(try db.alerts(triageFilter: .open).map(\.id).contains(newA.id), true) + } + + func testFocusAndTriageFiltersCompose() throws { + let openA = try add("a.app") + let investigatingA = try add("a.app") + let resolvedA = try add("a.app") + let dismissedB = try add("b.app") + try db.setAlertTriageState(.investigating, ids: [investigatingA.id]) + try db.setAlertTriageState(.resolved, ids: [resolvedA.id]) + try db.setAlertTriageState(.dismissed, ids: [dismissedB.id]) + + let a = FocusScope(bundleIDs: ["a.app"]) + XCTAssertEqual(Set(try db.alerts(focus: a, triageFilter: .open).map(\.id)), [openA.id, investigatingA.id]) + XCTAssertEqual(try db.alerts(focus: a, triageFilter: .investigating).map(\.id), [investigatingA.id]) + XCTAssertEqual(try db.alerts(focus: a, triageFilter: .resolved).map(\.id), [resolvedA.id]) + XCTAssertTrue(try db.alerts(focus: a, triageFilter: .dismissed).isEmpty) + } + + func testTriageDoesNotCreateOrModifyAllowRules() throws { + let alert = try add("agent.app", allowPattern: "api.example") + try db.setAlertTriageState(.dismissed, ids: [alert.id]) + try db.setAlertDisposition(.falsePositive, ids: [alert.id]) + + XCTAssertTrue(try db.loadPolicies().isEmpty) + let triaged = try XCTUnwrap(db.alerts().first) + XCTAssertEqual(triaged.allowPattern, "api.example") + XCTAssertEqual(triaged.state, .dismissed) + + try db.savePolicy(AgentPolicy(agentID: "agent.app", patterns: ["api.example"])) + let afterAllowRule = try XCTUnwrap(db.alerts().first) + XCTAssertEqual(afterAllowRule.state, .dismissed) + XCTAssertEqual(afterAllowRule.disposition, .falsePositive) + } +} diff --git a/FlowlightTests/DatabaseTests.swift b/FlowlightTests/DatabaseTests.swift index c9bd93f3..c4da4f28 100644 --- a/FlowlightTests/DatabaseTests.swift +++ b/FlowlightTests/DatabaseTests.swift @@ -44,6 +44,160 @@ final class DatabaseTests: XCTestCase { FlowKey(pid: 10, bundleID: app, appName: app, appPath: "", remoteIP: ip, domain: domain, port: port, transport: .tcp, appProtocol: "https") } + func testInspectedAgentIDsCoverRowsOlderThanBoundedExchangeDetails() throws { + let start = Date(timeIntervalSince1970: 1_900_000_000) + func exchange(index: Int, agent: String) -> HTTPExchange { + HTTPExchange( + id: nil, started: start.addingTimeInterval(TimeInterval(index)), duration: 0.01, + scheme: "https", host: "api.example", port: 443, method: "GET", path: "/", status: 200, + requestHeaders: [], requestBody: Data(), requestSize: 0, requestTruncated: false, + responseHeaders: [], responseBody: Data(), responseSize: 0, responseTruncated: false, + contentType: "application/json", pid: 10, bundleID: "agent.app", appName: "Agent", + agent: agent, agentName: agent, mcpServer: nil, toolCalls: [], note: nil, + mockRule: nil, + guardrail: index == 0 ? "old guardrail" + : (index == 5_000 ? "new guardrail" : (index == 6_000 ? "latest guardrail" : nil)) + ) + } + + _ = try db.insertExchange(exchange(index: 0, agent: "older-agent")) + for index in 1...5_000 { + _ = try db.insertExchange(exchange(index: index, agent: "newer-agent")) + } + + let details = try db.exchanges(since: start, limit: 5_000) + XCTAssertEqual(Set(details.compactMap(\.agent)), ["newer-agent"]) + let policyEffects = try db.exchanges(since: start, limit: nil, policyEffectsOnly: true) + XCTAssertEqual(policyEffects.compactMap(\.guardrail), ["new guardrail", "old guardrail"], + "policy activity must not lose an older effect behind ordinary exchange volume") + let firstPage = try db.exchanges(since: start, limit: 1, policyEffectsOnly: true) + XCTAssertEqual(firstPage.compactMap(\.guardrail), ["new guardrail"]) + let cursor = try XCTUnwrap(firstPage.first) + let cursorID = try XCTUnwrap(cursor.id) + _ = try db.insertExchange(exchange(index: 6_000, agent: "latest-agent")) + XCTAssertEqual(try db.exchanges(since: start, limit: 1, before: cursor.started, beforeID: cursorID, + policyEffectsOnly: true).compactMap(\.guardrail), ["old guardrail"], + "cursor-paged policy activity must retain older effects without loading the whole window") + XCTAssertEqual(try db.inspectedAgentIDs(since: start), ["older-agent", "newer-agent", "latest-agent"]) + XCTAssertEqual(try db.inspectedAgentIDs( + since: start, focus: FocusScope(bundleIDs: ["agent.app"]) + ), ["older-agent", "newer-agent", "latest-agent"]) + XCTAssertEqual(try db.inspectedAgentIDs( + since: start, focus: FocusScope(bundleIDs: ["older-agent"]) + ), ["older-agent"], "agent Focus must retain attributed child exchanges") + XCTAssertTrue(try db.inspectedAgentIDs( + since: start, focus: FocusScope(bundleIDs: ["other.app"]) + ).isEmpty) + } + + func testLegacyInspectionEffectHistoryIsPreservedConservativelyAndMarkedOnce() throws { + let base = Date(timeIntervalSince1970: 1_700_000_000) + func exchange(offset: TimeInterval, guardrail: String) -> HTTPExchange { + HTTPExchange( + id: nil, started: base.addingTimeInterval(offset), duration: 0.1, + scheme: "https", host: "api.example.com", port: 443, method: "POST", path: "/v1/run", + status: 200, requestHeaders: [], requestBody: Data(), requestSize: 0, + requestTruncated: false, responseHeaders: [], responseBody: Data(), responseSize: 0, + responseTruncated: false, contentType: "application/json", pid: 10, bundleID: "agent.app", + appName: "Agent", agent: "agent", agentName: "Agent", mcpServer: nil, toolCalls: [], + note: nil, mockRule: nil, guardrail: guardrail + ) + } + + let rewriteOnly = try db.insertExchange(exchange(offset: 0, guardrail: "set X-Test")) + let pathDelimiter = try db.insertExchange(exchange(offset: 5, guardrail: "set profile · email")) + let guardrailOnly = try db.insertExchange(exchange(offset: 10, guardrail: "removed shell")) + let combined = try db.insertExchange(exchange(offset: 20, guardrail: "removed shell · scrub body")) + let deletedGuardrail = try db.insertExchange(exchange(offset: 30, guardrail: "removed deleted tool")) + let correlated: [(TimeInterval, UUID)] = [(10, UUID()), (20, UUID()), (30, UUID())] + try db.saveGuardrail(Guardrail(id: correlated[0].1, agent: "agent", tool: "shell")) + try db.saveGuardrail(Guardrail(id: correlated[1].1, agent: "agent", tool: "shell")) + try db.recordRuleEvents(correlated.map { offset, id in + RuleEvent( + at: Int64(base.addingTimeInterval(offset).timeIntervalSince1970), ruleID: id, + action: .block, engine: .request, agentKey: "agent", agentName: "Agent", + appName: "Agent", bundleID: "agent.app", host: "api.example.com", ip: "", port: 443, + path: "/v1/run", method: "POST" + ) + }) + + let url = db.url + db = nil + let legacy = try SQLiteConnection(path: url.path) + try legacy.execute("ALTER TABLE http_exchanges DROP COLUMN request_rewrite") + + var migrated: TrafficDatabase? = try TrafficDatabase(url: url) + let effects = try XCTUnwrap(migrated).raw( + "SELECT id, guardrail, request_rewrite FROM http_exchanges ORDER BY id", + map: { ($0.int(0), $0.text(1), $0.text(2)) } + ) + XCTAssertEqual(effects.map(\.0), [rewriteOnly, pathDelimiter, guardrailOnly, combined, deletedGuardrail]) + XCTAssertEqual(effects.map(\.1), [ + "set X-Test", "set profile · email", "removed shell", "removed shell · scrub body", "removed deleted tool", + ]) + XCTAssertEqual(effects.map(\.2), ["", "", "", "", ""], + "ambiguous legacy text must never fabricate a second policy effect") + XCTAssertEqual(try XCTUnwrap(migrated).ruleEvents().map(\.engine), [.request, .guardrail, .guardrail]) + XCTAssertEqual(try XCTUnwrap(migrated).raw( + "SELECT watermark FROM rollup_state WHERE tier = 'schema.inspectionEffectSplit'", + map: { $0.int(0) } + ), [1]) + + let currentID = try XCTUnwrap(migrated).insertExchange(HTTPExchange( + id: nil, started: base.addingTimeInterval(40), duration: 0.1, + scheme: "https", host: "api.example.com", port: 443, method: "POST", path: "/v1/run", + status: 200, requestHeaders: [], requestBody: Data(), requestSize: 0, + requestTruncated: false, responseHeaders: [], responseBody: Data(), responseSize: 0, + responseTruncated: false, contentType: "application/json", pid: 10, bundleID: "agent.app", + appName: "Agent", agent: "agent", agentName: "Agent", mcpServer: nil, toolCalls: [], + note: nil, mockRule: nil, guardrail: "current guardrail", requestRewrite: "current rewrite" + )) + migrated = nil + + let reopened = try TrafficDatabase(url: url) + let current = try reopened.raw( + "SELECT guardrail, request_rewrite FROM http_exchanges WHERE id = \(currentID)", + map: { ($0.text(0), $0.text(1)) } + ) + XCTAssertEqual(current.first?.0, "current guardrail") + XCTAssertEqual(current.first?.1, "current rewrite") + } + + func testInspectionEffectSplitFinishesWhenPrereleaseColumnAlreadyExists() throws { + let base = Date(timeIntervalSince1970: 1_700_000_100) + func exchange(_ guardrail: String) -> HTTPExchange { + HTTPExchange( + id: nil, started: base, duration: 0.1, scheme: "https", host: "api.example.com", port: 443, + method: "POST", path: "/v1/run", status: 200, requestHeaders: [], requestBody: Data(), + requestSize: 0, requestTruncated: false, responseHeaders: [], responseBody: Data(), responseSize: 0, + responseTruncated: false, contentType: "application/json", pid: 10, bundleID: "agent.app", + appName: "Agent", agent: "agent", agentName: "Agent", mcpServer: nil, toolCalls: [], note: nil, + mockRule: nil, guardrail: guardrail + ) + } + let combined = try db.insertExchange(exchange("removed shell · scrub body")) + let ambiguous = try db.insertExchange(exchange("legacy localized note")) + let url = db.url + db = nil + + var prerelease: SQLiteConnection? = try SQLiteConnection(path: url.path) + try prerelease?.execute("DELETE FROM rollup_state WHERE tier = 'schema.inspectionEffectSplit'") + prerelease = nil + + let reopened = try TrafficDatabase(url: url) + let effects = try reopened.raw( + "SELECT id, guardrail, request_rewrite FROM http_exchanges ORDER BY id", + map: { ($0.int(0), $0.text(1), $0.text(2)) } + ) + XCTAssertEqual(effects.map(\.0), [combined, ambiguous]) + XCTAssertEqual(effects.map(\.1), ["removed shell · scrub body", "legacy localized note"]) + XCTAssertEqual(effects.map(\.2), ["", ""]) + XCTAssertEqual(try reopened.raw( + "SELECT watermark FROM rollup_state WHERE tier = 'schema.inspectionEffectSplit'", + map: { $0.int(0) } + ), [1]) + } + func testTrafficDateBoundsAreNilWhenEmptyAndCoverRetainedTraffic() throws { XCTAssertNil(try db.trafficDateBounds()) @@ -250,6 +404,9 @@ final class DatabaseTests: XCTestCase { RuleEvent(at: 1_700_000_011, ruleID: ruleID, action: .allow, engine: .flow, agentKey: "", agentName: "", appName: "Browser", bundleID: "com.browser", host: "analytics.google.com", ip: "", port: 443), + RuleEvent(at: 1_700_000_012, ruleID: UUID(), action: .block, engine: .guardrail, + agentKey: "claude", agentName: "Claude Code", appName: "Claude Code", bundleID: "claude", + host: "api.example.com", ip: "", port: 443, path: "shell", method: "POST"), RuleEvent(at: 1_700_000_100, ruleID: ruleID, action: .block, engine: .flow, agentKey: "", agentName: "", appName: "Browser", bundleID: "com.browser", host: "outside.example", ip: "", port: 443) @@ -290,13 +447,16 @@ final class DatabaseTests: XCTestCase { let attempts = try db.blockedAttempts(from: from, to: from.addingTimeInterval(60)) - XCTAssertEqual(attempts.reduce(Int64(0)) { $0 + $1.count }, 5) + XCTAssertEqual(attempts.reduce(Int64(0)) { $0 + $1.count }, 6) XCTAssertEqual(Set(attempts.map(\.destination)), - ["www.google-analytics.com", "telemetry.example", "uploads.example"]) + ["www.google-analytics.com", "telemetry.example", "uploads.example", "api.example.com"]) XCTAssertFalse(attempts.contains { $0.destination == "analytics.google.com" }) XCTAssertFalse(attempts.contains { $0.destination == "outside.example" }) XCTAssertFalse(attempts.contains { $0.destination == "duplicate.example" }) XCTAssertTrue(attempts.contains { $0.source == "managed-pack:official.privacy" && $0.count == 3 }) + XCTAssertTrue(attempts.contains { + $0.source == "guardrail" && $0.destination == "api.example.com" && $0.category == "guardrail" + }) XCTAssertTrue(attempts.contains { $0.source == "agent-allowlist" && $0.bundleID == "curl" && $0.agentKey == "claude" && $0.destination == "uploads.example" && $0.count == 1 @@ -1225,6 +1385,31 @@ final class DatabaseTests: XCTestCase { XCTAssertLessThan(AnomalyEngine.zScore(1_150_000, b!, floor: 64_000), 3) } + func testOpenAlertCountsAreGroupedFilteredAndWindowedInSQL() throws { + let start = Date(timeIntervalSince1970: 1_900_000_000) + _ = try db.addAlert(kind: "test", bundleID: "claude", appName: "Claude Code", + detail: "new", severity: 2, at: start.addingTimeInterval(10)) + let investigating = try db.addAlert(kind: "test", bundleID: "claude", appName: "Claude Code", + detail: "investigating", severity: 2, + at: start.addingTimeInterval(20)) + try db.setAlertTriageState(.investigating, ids: [investigating.id]) + let resolved = try db.addAlert(kind: "test", bundleID: "claude", appName: "Claude Code", + detail: "resolved", severity: 2, at: start.addingTimeInterval(30)) + try db.setAlertTriageState(.resolved, ids: [resolved.id]) + _ = try db.addAlert(kind: "test", bundleID: "codex", appName: "Codex", + detail: "other", severity: 2, at: start.addingTimeInterval(40)) + _ = try db.addAlert(kind: "test", bundleID: "claude", appName: "Claude Code", + detail: "old", severity: 2, at: start.addingTimeInterval(-10)) + + XCTAssertEqual(try db.openAlertCounts(since: start), [ + AgentOpenAlertCount(bundleID: "claude", appName: "Claude Code", count: 2), + AgentOpenAlertCount(bundleID: "codex", appName: "Codex", count: 1), + ]) + XCTAssertEqual(try db.openAlertCounts(since: start, focus: FocusScope(bundleIDs: ["claude"])), [ + AgentOpenAlertCount(bundleID: "claude", appName: "Claude Code", count: 2), + ]) + } + func testFirstContactAndPortRules() throws { final class NoActivity: ActivitySnapshotting { func idleDuration(bundleID: String) -> TimeInterval? { nil } } let engine = AnomalyEngine(db: db, activity: NoActivity()) diff --git a/FlowlightTests/HelpTests.swift b/FlowlightTests/HelpTests.swift index 6a22965f..3cce5866 100644 --- a/FlowlightTests/HelpTests.swift +++ b/FlowlightTests/HelpTests.swift @@ -24,7 +24,7 @@ final class HelpTests: XCTestCase { } func testTheHelpURLIsBuiltFromTheAnchorRatherThanGuessed() { - XCTAssertEqual(Help.forScreen(.rules).absoluteString, "https://flowlight.xinbetween.com/docs/#rules") + XCTAssertEqual(Help.forScreen(.rules).absoluteString, "https://flowlight.xinbetween.com/docs/#policies") XCTAssertEqual(Help.forScreen(.inspect).absoluteString, "https://flowlight.xinbetween.com/docs/#inspection") } diff --git a/FlowlightTests/InsightsTests.swift b/FlowlightTests/InsightsTests.swift index 5d8eba91..a9dca307 100644 --- a/FlowlightTests/InsightsTests.swift +++ b/FlowlightTests/InsightsTests.swift @@ -161,6 +161,24 @@ final class InsightsTests: XCTestCase { XCTAssertEqual(ReportChartHoverLayout.cardX(lineX: 10, chartWidth: 200), 0) } + func testReportHoverRejectsPointersOutsideThePlotArea() { + let plot = CGRect(x: 40, y: 20, width: 600, height: 240) + + XCTAssertTrue(ReportChartHoverLayout.contains(CGPoint(x: 320, y: 100), in: plot)) + XCTAssertFalse(ReportChartHoverLayout.contains(CGPoint(x: 20, y: 100), in: plot), + "the y-axis and leading chart furniture are not time buckets") + XCTAssertFalse(ReportChartHoverLayout.contains(CGPoint(x: 320, y: 280), in: plot), + "leaving the plot must clear transient hover") + XCTAssertFalse(ReportChartHoverLayout.contains(CGPoint(x: plot.minX, y: 100), in: plot), + "a hover clamped to the leading edge means the pointer has left the plot") + XCTAssertFalse(ReportChartHoverLayout.contains(CGPoint(x: plot.maxX, y: 100), in: plot), + "a hover clamped to the trailing edge means the pointer has left the plot") + XCTAssertTrue(ReportChartHoverLayout.contains(CGPoint(x: plot.minX + 1, y: 100), in: plot), + "the first bucket remains inspectable inside the plot") + XCTAssertTrue(ReportChartHoverLayout.contains(CGPoint(x: plot.maxX - 1, y: 100), in: plot), + "the last bucket remains inspectable inside the plot") + } + func testReportChartKeyboardSelectionMovesAcrossBuckets() { let dates = [10.0, 20.0, 30.0].map(Date.init(timeIntervalSince1970:)) @@ -171,6 +189,51 @@ final class InsightsTests: XCTestCase { XCTAssertNil(ReportChartKeyboardSelection.move(from: nil, direction: .right, dates: [])) } + func testReportChartFocusDoesNotCreateAnUpperLeftSelection() { + let dates = [10.0, 20.0, 30.0].map(Date.init(timeIntervalSince1970:)) + + XCTAssertNil(ReportChartKeyboardSelection.reconcile(nil, chartFocused: true, dates: dates), + "focus alone must not synthesize a first-bucket tooltip") + XCTAssertEqual( + ReportChartHighlightSelection.date(hovered: dates[1], keyboard: dates[0], chartFocused: true), + dates[1], + "pointer inspection takes precedence over a previous keyboard selection" + ) + XCTAssertNil(ReportChartHighlightSelection.date(hovered: nil, keyboard: nil, chartFocused: true), + "leaving the chart after pointer inspection must not reveal a synthetic keyboard tooltip") + XCTAssertEqual( + ReportChartHighlightSelection.date(hovered: nil, keyboard: dates[0], chartFocused: true), + dates[0], + "leaving pointer inspection restores an intentional keyboard selection" + ) + XCTAssertEqual( + ReportChartKeyboardSelection.reconcile(dates[2], chartFocused: true, dates: Array(dates.prefix(2))), + dates[0], + "an intentional keyboard selection remains usable when the series changes" + ) + } + + func testLiveChartKeyboardAndPointerSelectionStayIndependent() { + let dates = [10.0, 20.0, 30.0].map(Date.init(timeIntervalSince1970:)) + + XCTAssertEqual(LiveChartSelection.move(from: nil, direction: .right, dates: dates), dates.first) + XCTAssertEqual(LiveChartSelection.move(from: nil, direction: .left, dates: dates), dates.last) + XCTAssertEqual(LiveChartSelection.move(from: dates[1], direction: .left, dates: dates), dates[0]) + XCTAssertEqual(LiveChartSelection.move(from: dates[1], direction: .right, dates: dates), dates[2]) + XCTAssertEqual(LiveChartSelection.date(hovered: dates[2], keyboard: dates[0], chartFocused: true), dates[2]) + XCTAssertEqual(LiveChartSelection.date(hovered: nil, keyboard: dates[0], chartFocused: true), dates[0]) + XCTAssertNil(LiveChartSelection.date(hovered: nil, keyboard: dates[0], chartFocused: false)) + } + + func testLiveChartSelectionReconcilesToTheNearestRetainedSecond() { + let current = Date(timeIntervalSince1970: 20) + let dates = [21.0, 22.0, 23.0].map(Date.init(timeIntervalSince1970:)) + + XCTAssertEqual(LiveChartSelection.reconcile(current, dates: dates), dates[0]) + XCTAssertNil(LiveChartSelection.reconcile(nil, dates: dates)) + XCTAssertNil(LiveChartSelection.reconcile(current, dates: [])) + } + func testHistoryPresetUsesAnIndependentVisibleWindowAndResolution() { let end = Date(timeIntervalSince1970: 2_000_000) let available = Date(timeIntervalSince1970: 0)...end diff --git a/FlowlightTests/InspectNavigationTests.swift b/FlowlightTests/InspectNavigationTests.swift index ec765c97..62d8602c 100644 --- a/FlowlightTests/InspectNavigationTests.swift +++ b/FlowlightTests/InspectNavigationTests.swift @@ -30,14 +30,15 @@ final class InspectNavigationTests: XCTestCase { XCTAssertEqual(SidebarItem.inspect.section, .investigate) } - func testPackReviewNavigationOpensThePacksTab() { + func testPackReviewNavigationFocusesManagedPacksInPolicies() { let nav = AppNavigation() nav.show(.live) nav.showPackReviews() XCTAssertEqual(nav.selection, .rules) - XCTAssertEqual(nav.rulesTab, .packs) + XCTAssertEqual(nav.rulesTab, .rules) + XCTAssertEqual(nav.policyRequest?.destination, .managedPack(nil)) } func testOrdinaryRulesNavigationReturnsToTheRulesTab() { @@ -60,4 +61,306 @@ final class InspectNavigationTests: XCTestCase { XCTAssertEqual(nav.rulesTab, .rules) XCTAssertNotNil(nav.ruleRequest) } + + func testLegacyRulesTabsRestoreIntoTwoTabPoliciesUI() { + XCTAssertEqual(RulesTab.rules.rawValue, "rules") + XCTAssertEqual(RulesTab.restored(from: "rules"), .rules) + XCTAssertEqual(RulesTab.restored(from: "packs"), .rules) + XCTAssertTrue(RulesTab.restoresManagedPackFocus(from: "packs")) + XCTAssertFalse(RulesTab.restoresManagedPackFocus(from: "rules")) + XCTAssertEqual(RulesTab.restored(from: "activity"), .activity) + XCTAssertEqual(RulesTab.allCases, [.rules, .activity]) + } + + func testPolicyInventoryIDsAreStableAndNamespaced() { + let source = "8f183e44-895b-4f05-b221-9d845e772def" + XCTAssertEqual(PolicyInventoryID.make(.networkRule, sourceID: source), "rule:\(source)") + XCTAssertEqual(PolicyInventoryID.make(.networkRule, sourceID: source), + PolicyInventoryID.make(.networkRule, sourceID: source)) + XCTAssertNotEqual(PolicyInventoryID.make(.networkRule, sourceID: source), + PolicyInventoryID.make(.guardrail, sourceID: source)) + } + + func testStoreCapabilitiesOmitEveryInspectionDependentPolicyKind() { + let capabilities = PolicyInventoryCapabilities(inspectionPolicies: false) + let items = PolicyInventoryKind.allCases.map { kind in + PolicyInventoryItem(kind: kind, sourceID: kind.rawValue, title: kind.rawValue, detail: "", + isEnabled: true, effect: .effective, reason: nil, destination: .managedPack(nil)) + } + let visible = items.filter { $0.isVisible(in: capabilities) }.map(\.kind) + + XCTAssertEqual(visible, [.networkRule, .agentAllowlist, .managedPack]) + XCTAssertFalse(visible.contains(.guardrail)) + XCTAssertFalse(visible.contains(.mock)) + XCTAssertFalse(visible.contains(.requestRewrite)) + XCTAssertFalse(visible.contains(.responseTransform)) + } + + func testInventorySeparatesConfiguredStateFromCurrentEffect() throws { + let rule = Rule(action: .block, destination: "example.com") + let allowlist = AgentPolicy(agentID: "agent", enabled: true, patterns: ["example.com"], enforce: false) + let guardrail = Guardrail(agent: "agent", tool: "bash") + let pack = try XCTUnwrap(BuiltInRulePacks.all.first) + let retired = RulePackConfiguration(packID: "retired.pack", enabled: true) + + let items = PolicyInventoryBuilder.build( + rules: [rule], allowlists: ["agent": allowlist], packs: [pack], unavailablePacks: [retired], + packConfiguration: { RulePackConfiguration(packID: $0, enabled: true) }, + packNeedsReview: { _ in true }, packStatus: .enforcing, + guardrails: [guardrail], mocks: [], rewrites: [], transforms: [], + extensionRunning: false, canBlock: true, inspectionEnabled: false, inspectionRunning: false) + + let network = try XCTUnwrap(items.first { $0.kind == .networkRule }) + XCTAssertTrue(network.isEnabled) + XCTAssertEqual(network.effect, .watchingOnly) + XCTAssertNotNil(network.reason) + + let agent = try XCTUnwrap(items.first { $0.kind == .agentAllowlist }) + XCTAssertTrue(agent.isEnabled) + XCTAssertEqual(agent.effect, .reportingOnly) + XCTAssertNotNil(agent.reason) + + let developer = try XCTUnwrap(items.first { $0.kind == .guardrail }) + XCTAssertTrue(developer.isEnabled) + XCTAssertEqual(developer.effect, .waiting) + XCTAssertNotNil(developer.reason) + + let managed = try XCTUnwrap(items.first { $0.sourceID == pack.id }) + XCTAssertTrue(managed.isEnabled) + XCTAssertEqual(managed.effect, .needsReview) + + let unavailable = try XCTUnwrap(items.first { $0.sourceID == "retired.pack" }) + XCTAssertTrue(unavailable.isEnabled) + XCTAssertEqual(unavailable.effect, .unavailable) + XCTAssertNotNil(unavailable.reason) + } + + func testPackFocusCarriesExactPackIdentity() { + let nav = AppNavigation() + nav.showPolicyPack("official.trackers") + + XCTAssertEqual(nav.selection, .rules) + XCTAssertEqual(nav.rulesTab, .rules) + XCTAssertEqual(nav.policyRequest?.destination, .managedPack("official.trackers")) + } + + func testPolicyDeepLinksKeepContextualEditorsAsOwners() { + let nav = AppNavigation() + let guardrailID = UUID() + nav.show(.rules) + nav.showAgentPolicy(agentID: "claude", section: .guardrails, guardrailID: guardrailID) + XCTAssertEqual(nav.selection, .agents) + XCTAssertEqual(nav.agentPolicyRequest?.agentID, "claude") + XCTAssertEqual(nav.agentPolicyRequest?.section, .guardrails) + XCTAssertEqual(nav.agentPolicyRequest?.guardrailID, guardrailID) + + let rewriteID = UUID() + nav.showInspectionPolicy(.requestRewrites, policyID: rewriteID) + XCTAssertEqual(nav.selection, .inspect) + XCTAssertEqual(nav.inspectRequest?.policySection, .requestRewrites) + XCTAssertEqual(nav.inspectRequest?.policyID, rewriteID) + } + + func testAgentPolicyDeepLinkIsScopedAndConsumedOnce() { + let guardrailID = UUID() + let request = AgentPolicyRequest(agentID: "claude", section: .guardrails, guardrailID: guardrailID) + var state = AgentPolicyDeepLinkState(request: request) + + XCTAssertEqual(state.section(for: "claude"), .guardrails) + XCTAssertEqual(state.section(for: ["claude-code", "Claude Code"]), .guardrails) + XCTAssertEqual(state.guardrailID(for: ["claude-code", "Claude Code"]), guardrailID) + XCTAssertNil(state.section(for: ["CLAUDE"]), "ambiguous case-folded identities must not pick a product") + XCTAssertNil(state.section(for: ["codex", "com.example.Codex"])) + XCTAssertNil(state.guardrailID(for: ["codex", "com.example.Codex"])) + XCTAssertEqual(AgentDetail.Tab.policyLanding(.guardrails), .guardrails) + XCTAssertEqual(AgentDetail.Tab.policyLanding(.allowlist), .destinations) + + state.consume() + XCTAssertNil(state.section(for: "claude")) + XCTAssertNil(state.guardrailID(for: ["claude"])) + } + + func testUnifiedActivityUsesStableInspectionPolicyIdentities() throws { + let guardrailID = UUID() + let rewriteID = UUID() + let transformID = UUID() + let guardrail = try XCTUnwrap(InspectionPolicyReference.encode([ + .init(id: guardrailID, title: "Shell guardrail"), + ])) + let rewrite = try XCTUnwrap(InspectionPolicyReference.encode([ + .init(id: rewriteID, title: "Scrub request"), + ])) + let transform = try XCTUnwrap(InspectionPolicyReference.encode([ + .init(id: transformID, title: "Shape response"), + ])) + let exchange = HTTPExchange( + id: 43, started: Date(timeIntervalSince1970: 100), duration: 0.2, scheme: "https", + host: "api.example.com", port: 443, method: "POST", path: "/v1/run", status: 200, + requestHeaders: [], requestBody: Data(), requestSize: 0, requestTruncated: false, + responseHeaders: [], responseBody: Data(), responseSize: 0, responseTruncated: false, + contentType: "application/json", pid: 1, bundleID: "agent", appName: "Agent", + agent: "agent", agentName: "Agent", mcpServer: nil, toolCalls: [], toolResults: [], mcp: [], llm: nil, + note: nil, mockRule: nil, guardrail: guardrail, requestRewrite: rewrite, + responseTransform: transform) + + let activity = PolicyActivityBuilder.build(ruleEvents: [], packEvents: [], alerts: [], exchanges: [exchange], + rules: [], + guardrails: [Guardrail(id: guardrailID, tool: "bash", + name: "Renamed guardrail")], + packs: [], mocks: [], + rewrites: [RewriteRule(id: rewriteID, name: "Renamed rewrite")], + transforms: [ResponseTransformRule(id: transformID, + name: "Renamed transform")]) + + XCTAssertEqual(Set(activity.compactMap(\.policyID)), [ + PolicyInventoryID.make(.guardrail, sourceID: guardrailID.uuidString.lowercased()), + PolicyInventoryID.make(.requestRewrite, sourceID: rewriteID.uuidString.lowercased()), + PolicyInventoryID.make(.responseTransform, sourceID: transformID.uuidString.lowercased()), + ]) + XCTAssertEqual(Set(activity.map(\.title)), ["Shell guardrail", "Scrub request", "Shape response"]) + XCTAssertEqual(InspectionPolicyReference.display(transform), "Shape response") + } + + func testDeletedInspectionPolicyActivityKeepsHistoryWithoutNavigation() throws { + let guardrailID = UUID() + let reference = try XCTUnwrap(InspectionPolicyReference.encode([ + .init(id: guardrailID, title: "Deleted shell guardrail"), + ])) + let exchange = HTTPExchange( + id: 44, started: Date(timeIntervalSince1970: 100), duration: 0.2, scheme: "https", + host: "api.example.com", port: 443, method: "POST", path: "/v1/run", status: 200, + requestHeaders: [], requestBody: Data(), requestSize: 0, requestTruncated: false, + responseHeaders: [], responseBody: Data(), responseSize: 0, responseTruncated: false, + contentType: "application/json", pid: 1, bundleID: "agent", appName: "Agent", + agent: "agent", agentName: "Agent", mcpServer: nil, toolCalls: [], toolResults: [], mcp: [], llm: nil, + note: nil, mockRule: nil, guardrail: reference, requestRewrite: nil, + responseTransform: nil) + + let activity = PolicyActivityBuilder.build(ruleEvents: [], packEvents: [], alerts: [], exchanges: [exchange], + rules: [], guardrails: [], packs: [], mocks: [], rewrites: [], + transforms: []) + + XCTAssertEqual(activity.first?.title, "Deleted shell guardrail") + XCTAssertNil(activity.first?.policyID) + } + + func testMockActivityKeepsStableAndUncorrelatedLegacyHistory() throws { + let mockID = UUID() + let stableReference = try XCTUnwrap(InspectionPolicyReference.encode([ + .init(id: mockID, title: "Original mock title"), + ])) + func exchange(id: Int64, mockRule: String, offset: TimeInterval = 0) -> HTTPExchange { + HTTPExchange( + id: id, started: Date(timeIntervalSince1970: 100 + offset), duration: 0.2, scheme: "https", + host: "api.example.com", port: 443, method: "POST", path: "/v1/run", status: 500, + requestHeaders: [], requestBody: Data(), requestSize: 0, requestTruncated: false, + responseHeaders: [], responseBody: Data(), responseSize: 0, responseTruncated: false, + contentType: "application/json", pid: 1, bundleID: "agent", appName: "Agent", + agent: "agent", agentName: "Agent", mcpServer: nil, toolCalls: [], toolResults: [], mcp: [], llm: nil, + note: nil, mockRule: mockRule, guardrail: nil, requestRewrite: nil, responseTransform: nil) + } + + let current = PolicyActivityBuilder.build( + ruleEvents: [], packEvents: [], alerts: [], exchanges: [exchange(id: 45, mockRule: stableReference)], + rules: [], guardrails: [], packs: [], + mocks: [MockRule(id: mockID, name: "Renamed mock")], rewrites: [], transforms: []) + XCTAssertEqual(current.first?.title, "Original mock title") + XCTAssertEqual(current.first?.policyID, + PolicyInventoryID.make(.mock, sourceID: mockID.uuidString.lowercased())) + + let deleted = PolicyActivityBuilder.build( + ruleEvents: [], packEvents: [], alerts: [], + exchanges: [exchange(id: 46, mockRule: stableReference), + exchange(id: 47, mockRule: "Legacy deleted mock", offset: 10)], + rules: [], guardrails: [], packs: [], mocks: [], rewrites: [], transforms: []) + XCTAssertEqual(Set(deleted.map(\.title)), ["Original mock title", "Legacy deleted mock"]) + XCTAssertTrue(deleted.allSatisfy { $0.policyID == nil }) + } + + func testAllowlistActivityUsesThePersistedPolicyIdentity() throws { + let policy = AgentPolicy(agentID: "claude", patterns: ["api.example"]) + let alert = AlertRecord( + id: 49, + timestamp: Date(timeIntervalSince1970: 100), + kind: AnomalyEngine.Kind.allowlistViolation.rawValue, + bundleID: "claude-code", + appName: "Claude Code", + detail: "Claude Code contacted uploads.example", + severity: 2, + state: .new + ) + + let activity = PolicyActivityBuilder.build( + ruleEvents: [], packEvents: [], alerts: [alert], exchanges: [], rules: [], + guardrails: [], packs: [], mocks: [], rewrites: [], transforms: [], policies: [policy] + ) + + XCTAssertEqual( + try XCTUnwrap(activity.first).policyID, + PolicyInventoryID.make(.agentAllowlist, sourceID: "claude") + ) + } + + func testLegacyRefusalAnswerDoesNotAppearAsMockActivity() { + let exchange = HTTPExchange( + id: 48, started: Date(timeIntervalSince1970: 100), duration: 0.2, scheme: "https", + host: "api.example.com", port: 443, method: "POST", path: "/v1/run", status: 200, + requestHeaders: [], requestBody: Data(), requestSize: 0, requestTruncated: false, + responseHeaders: [], responseBody: Data(), responseSize: 0, responseTruncated: false, + contentType: "application/json", pid: 1, bundleID: "agent", appName: "Agent", + agent: "agent", agentName: "Agent", mcpServer: nil, toolCalls: [], toolResults: [], mcp: [], llm: nil, + note: nil, mockRule: "Flowlight blocked this request", guardrail: nil, + requestRewrite: nil, responseTransform: nil) + let event = RuleEventRecord( + id: 8, timestamp: exchange.started, ruleID: UUID(), action: .block, engine: .guardrail, + bundleID: "agent", appName: "Agent", agentKey: "agent", agentName: "Agent", + host: exchange.host, ip: "", port: 443, path: "shell", method: exchange.method) + + let activity = PolicyActivityBuilder.build( + ruleEvents: [event], packEvents: [], alerts: [], exchanges: [exchange], rules: [], + guardrails: [], packs: [], mocks: [], rewrites: [], transforms: []) + + XCTAssertEqual(activity.count, 1) + XCTAssertEqual(activity.first?.kind, .guardrail) + } + + func testDeletedGuardrailEventRetainsItsKind() { + let event = RuleEventRecord( + id: 7, timestamp: Date(timeIntervalSince1970: 100), ruleID: UUID(), action: .block, + engine: .guardrail, bundleID: "agent", appName: "Agent", agentKey: "agent", agentName: "Agent", + host: "api.example.com", ip: "", port: 443, path: "shell", method: "POST" + ) + + let activity = PolicyActivityBuilder.build(ruleEvents: [event], packEvents: [], alerts: [], exchanges: [], + rules: [], guardrails: [], packs: [], mocks: [], rewrites: [], + transforms: []) + + XCTAssertEqual(activity.first?.kind, .guardrail) + XCTAssertEqual(activity.first?.effect, .guardrailApplied) + } + + func testUnifiedActivityKeepsGuardrailAndRewriteEffectsDistinct() { + let exchange = HTTPExchange( + id: 42, started: Date(timeIntervalSince1970: 100), duration: 0.2, scheme: "https", + host: "api.example.com", port: 443, method: "POST", path: "/v1/run", status: 200, + requestHeaders: [], requestBody: Data(), requestSize: 0, requestTruncated: false, + responseHeaders: [], responseBody: Data(), responseSize: 0, responseTruncated: false, + contentType: "application/json", pid: 1, bundleID: "agent", appName: "Agent", + agent: "agent", agentName: "Agent", mcpServer: nil, toolCalls: [], toolResults: [], mcp: [], llm: nil, + note: nil, mockRule: nil, guardrail: "Removed shell", requestRewrite: "set X-Test", + responseTransform: nil) + + let activity = PolicyActivityBuilder.build(ruleEvents: [], packEvents: [], alerts: [], exchanges: [exchange], + rules: [], guardrails: [], packs: [], mocks: [], rewrites: [], + transforms: []) + + XCTAssertEqual(Set(activity.map(\.effect)), [.guardrailApplied, .requestRewritten]) + XCTAssertEqual(Set(activity.map(\.id)), ["exchange:42:guardrail", "exchange:42:request-rewrite"]) + + let storeActivity = PolicyActivityBuilder.build( + ruleEvents: [], packEvents: [], alerts: [], exchanges: [exchange], rules: [], guardrails: [], + packs: [], mocks: [], rewrites: [], transforms: [], + capabilities: PolicyInventoryCapabilities(inspectionPolicies: false)) + XCTAssertTrue(storeActivity.isEmpty, "Store activity must omit retained Developer-only effects") + } } diff --git a/FlowlightTests/MockResponseTests.swift b/FlowlightTests/MockResponseTests.swift index 0246ef90..3485d2eb 100644 --- a/FlowlightTests/MockResponseTests.swift +++ b/FlowlightTests/MockResponseTests.swift @@ -144,6 +144,14 @@ final class MockResponseBytesTests: XCTestCase { XCTAssertTrue(text.hasSuffix("\r\n\r\n" + #"{"error":"busy"}"#)) } + func testStableRecordingReferenceDoesNotReplaceClientFacingTitle() { + let reference = "flowlight-policy-v1:opaque" + let rule = MockRule(name: "Service unavailable", host: "a.test", recordingReference: reference) + let text = String(decoding: rule.responseBytes(), as: UTF8.self) + XCTAssertTrue(text.contains("X-Flowlight-Mock: Service unavailable\r\n")) + XCTAssertFalse(text.contains(reference)) + } + func testStatusesWithoutABodyGetNone() { let text = String(decoding: MockRule(host: "a.test", status: 304, body: "ignored").responseBytes(), as: UTF8.self) XCTAssertTrue(text.hasPrefix("HTTP/1.1 304 Not Modified\r\n")) @@ -197,13 +205,30 @@ final class MockGateTests: XCTestCase { case .forward(let d): forwarded.append(d); recorded.append(d) case .hold(let d): recorded.append(d) case .answer(let rule, let d, _): answered.append(rule.title); recorded.append(d) - case .rewritten(let d, _): recorded.append(d) + case .rewritten(let d, _, _): recorded.append(d) } } } return (String(decoding: forwarded, as: UTF8.self), answered, String(decoding: recorded, as: UTF8.self)) } + func testInterventionPreservesGuardrailAndRewriteMetadataSeparately() { + let subject = gate([]) + subject.intervene = { _, bytes in + .replace(bytes, guardrail: "guardrail-id", rewrite: "rewrite-id") + } + let request = Data("GET /v1/items HTTP/1.1\r\nHost: api.example.com\r\n\r\n".utf8) + + let actions = subject.clientSent(request) + + guard case .rewritten(let bytes, let guardrail, let rewrite) = actions.first else { + return XCTFail("expected rewritten action") + } + XCTAssertEqual(bytes, request) + XCTAssertEqual(guardrail, "guardrail-id") + XCTAssertEqual(rewrite, "rewrite-id") + } + func testNonMatchingRequestIsUntouched() { let request = "GET /v1/other HTTP/1.1\r\nHost: api.example.com\r\n\r\n" let result = run(gate(), [request]) diff --git a/FlowlightTests/PerformanceBudgetTests.swift b/FlowlightTests/PerformanceBudgetTests.swift new file mode 100644 index 00000000..79da2af8 --- /dev/null +++ b/FlowlightTests/PerformanceBudgetTests.swift @@ -0,0 +1,332 @@ +import CoreGraphics +import XCTest +@testable import Flowlight + +@MainActor +final class PerformanceBudgetTests: XCTestCase { + private let clock = ContinuousClock() + + func testLaunchStartupBudget() async throws { + let recordCount = 10_001 + let url = temporaryDatabaseURL("launch") + defer { removeTemporaryDatabase(url) } + let db = try TrafficDatabase(url: url) + try db.insert([TrafficBatch(timestamp: fixedTimestamp, records: trafficRecords(count: recordCount))]) + let source = BudgetTrafficSource() + + let monitor: TrafficMonitor = try await assertBudget( + "launch startup", fixture: "\(recordCount) retained traffic rows", + budgetMilliseconds: 1_000 + ) { + let monitor = TrafficMonitor(databaseURL: url, trafficSourceFactory: { source }) + monitor.start() + for _ in 0..<10_000 { + if source.started { return monitor } + try await Task.sleep(for: .milliseconds(1)) + } + XCTFail("launch startup fixture never started its traffic source") + return monitor + } + XCTAssertTrue(source.started) + XCTAssertFalse(monitor.rulePacks.definitions.isEmpty) + } + + func testLargeGraphLayoutBudget() throws { + let edgeCount = 600 + let rows = (0..( + _ name: String, + fixture: String, + budgetMilliseconds: Double, + operation: () throws -> T + ) rethrows -> T { + let started = clock.now + let result = try operation() + let observed = milliseconds(clock.now - started) + print(String(format: "PERF_BUDGET %@ fixture=%@ observed=%.3fms budget=%.3fms", name, fixture, + observed, budgetMilliseconds)) + if let failure = budgetFailure( + name: name, fixture: fixture, + observedMilliseconds: observed, budgetMilliseconds: budgetMilliseconds + ) { + XCTFail(failure) + } + return result + } + + private func assertBudget( + _ name: String, + fixture: String, + budgetMilliseconds: Double, + operation: () async throws -> T + ) async rethrows -> T { + let started = clock.now + let result = try await operation() + let observed = milliseconds(clock.now - started) + print(String(format: "PERF_BUDGET %@ fixture=%@ observed=%.3fms budget=%.3fms", name, fixture, + observed, budgetMilliseconds)) + if let failure = budgetFailure( + name: name, fixture: fixture, + observedMilliseconds: observed, budgetMilliseconds: budgetMilliseconds + ) { + XCTFail(failure) + } + return result + } + + private func budgetFailure( + name: String, + fixture: String, + observedMilliseconds: Double, + budgetMilliseconds: Double + ) -> String? { + guard observedMilliseconds > budgetMilliseconds else { return nil } + return String( + format: "%@: fixture=%@ observed=%.3fms budget=%.3fms", + name, fixture, observedMilliseconds, budgetMilliseconds + ) + } + + private func milliseconds(_ duration: Duration) -> Double { + let parts = duration.components + return Double(parts.seconds) * 1_000 + Double(parts.attoseconds) / 1_000_000_000_000_000 + } + + private func trafficRecords(count: Int) -> [TrafficRecord] { + (0.. (series: [SeriesPoint], breakdown: ReportsBreakdown) { + try db.readSnapshot { + ( + try db.series(.second, from: from, to: to, calendar: calendar), + try db.reportsBreakdown(.second, from: from, to: to) + ) + } + } + + private func inventorySummary(index: Int) -> AgentSummary { + let counters = FlowCounters(bytesIn: 400, bytesOut: 100, flows: 1) + return AgentSummary( + bundleID: "worker.\(index)", name: "Worker \(index)", vendor: nil, isKnown: false, appPath: "", + ai: counters, egress: FlowCounters(), providers: [("OpenAI", counters)], + destinations: [ + AgentDestination( + label: "api.provider.test", ip: "203.0.113.10", provider: "OpenAI", + protocols: ["https"], ports: "443", counters: counters + ) + ], + alerts: 0 + ) + } + + private func pluginExchange() -> HTTPExchange { + HTTPExchange( + id: nil, started: Date(timeIntervalSince1970: TimeInterval(fixedTimestamp)), duration: 0.2, + scheme: "https", host: "api.example.test", port: 443, method: "DELETE", + path: "/admin/delete?token=redacted", + status: 200, + requestHeaders: [ + HTTPHeader(name: "Authorization", value: "Bearer redacted"), + HTTPHeader(name: "X-API-Key", value: "redacted"), + ], + requestBody: Data("{\"value\":\"redacted\"}".utf8), requestSize: 700_000, requestTruncated: false, + responseHeaders: [], responseBody: Data(), responseSize: 0, responseTruncated: false, + contentType: "application/json", pid: 1, bundleID: "agent", appName: "Agent", + agent: "agent", agentName: "Agent", mcpServer: nil, + toolCalls: [], toolResults: [], mcp: [], llm: nil, note: nil, mockRule: nil, guardrail: nil + ) + } + + private func temporaryDatabaseURL(_ name: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-performance-\(name)-\(UUID().uuidString).sqlite") + } + + private func removeTemporaryDatabase(_ url: URL) { + for suffix in ["", "-wal", "-shm"] { + try? FileManager.default.removeItem(atPath: url.path + suffix) + } + } +} + +private final class BudgetTrafficSource: TrafficSource { + let displayName = "Performance budget source" + private(set) var started = false + + func start(sink: @escaping ([TrafficBatch]) -> Void, status: @escaping (String) -> Void) { + started = true + status("ready") + } + + func stop() {} + func setRulePacks(_ snapshot: RulePackSnapshot?) {} +} diff --git a/FlowlightTests/PluginTests.swift b/FlowlightTests/PluginTests.swift index bfa2ff55..45aeef13 100644 --- a/FlowlightTests/PluginTests.swift +++ b/FlowlightTests/PluginTests.swift @@ -513,6 +513,111 @@ final class PluginTests: XCTestCase { XCTAssertFalse(text.contains("do-not-leak")) } + func testInstalledPluginReceivesOnlyMatchingCredentialFreeProviderProfiles() throws { + let db = try database() + var manifest = PluginManifest( + id: "thirdparty.provider-context", name: "Provider Context", version: "0.1.0", kind: .traffic, + source: .installed, publisher: .thirdParty, guardrailProvider: .presidio, + description: "JS plugin.", privacySummary: "Uses local provider metadata only." + ) + manifest.configuration = ["mode": "manifest-local"] + manifest.script = """ + function evaluate(context) { + const profiles = context.manifest.providerProfiles || []; + if (profiles.length !== 1) return []; + const profile = profiles[0]; + return [{ + severity: 'low', + title: 'Provider profile visible', + summary: [profile.provider, profile.endpoint, profile.configuration.policy, + context.manifest.configuration.mode, + Object.keys(profile).sort().join(',')].join('|'), + evidence: [] + }]; + } + """ + try db.savePluginManifest(manifest) + db.replaceProviderProfiles([ + ProviderRuntimeProfile(id: UUID(), provider: .bedrock, endpoint: "https://wrong.example", + configuration: ["policy": "wrong-provider"], pluginIDs: [manifest.id]), + ProviderRuntimeProfile(id: UUID(), provider: .presidio, endpoint: "https://unassigned.example", + configuration: ["policy": "unassigned"], pluginIDs: ["another.plugin"]), + ProviderRuntimeProfile(id: UUID(), provider: .presidio, endpoint: "http://localhost:3000/analyze", + configuration: ["policy": "strict"], pluginIDs: [manifest.id]), + ]) + + let id = try db.insertExchange(exchange(requestSize: 10, headers: [])) + let finding = try XCTUnwrap( + try db.pluginFindings(exchangeIDs: [id])[id]?.first { $0.pluginID == manifest.id } + ) + XCTAssertEqual( + finding.summary, + "presidio|http://localhost:3000/analyze|strict|manifest-local|configuration,endpoint,id,provider" + ) + XCTAssertFalse(finding.summary.localizedCaseInsensitiveContains("credential")) + XCTAssertFalse(finding.summary.contains("keychain:")) + } + + func testSeveralValidProviderProfilesStayWithinHelperTransportEnvelope() throws { + let db = try database() + var manifest = PluginManifest( + id: "thirdparty.many-provider-contexts", name: "Many Provider Contexts", version: "0.1.0", + kind: .traffic, source: .installed, publisher: .thirdParty, guardrailProvider: .custom, + description: "JS plugin.", privacySummary: "Uses local provider metadata only." + ) + manifest.script = """ + function evaluate(context) { + const count = (context.manifest.providerProfiles || []).length; + if (count !== 30) return []; + return [{ severity: 'low', title: 'All profiles visible', summary: String(count), evidence: [] }]; + } + """ + try db.savePluginManifest(manifest) + let configuration = Dictionary(uniqueKeysWithValues: (0..<32).map { + ("option\($0)", String(repeating: "safe value ", count: 50)) + }) + db.replaceProviderProfiles((0..<30).map { index in + ProviderRuntimeProfile(id: UUID(), provider: .custom, + endpoint: "https://provider-\(index).example.test/analyze", + configuration: configuration, pluginIDs: [manifest.id]) + }) + + let id = try db.insertExchange(exchange(requestSize: 10, headers: [])) + let finding = try XCTUnwrap( + try db.pluginFindings(exchangeIDs: [id])[id]?.first { $0.pluginID == manifest.id } + ) + XCTAssertEqual(finding.summary, "30") + } + + func testProviderProfileSnapshotIsUsedByBackfill() throws { + let db = try database() + var manifest = PluginManifest( + id: "thirdparty.provider-backfill", name: "Provider Backfill", version: "0.1.0", kind: .traffic, + source: .installed, publisher: .thirdParty, guardrailProvider: .custom, + description: "JS plugin.", privacySummary: "Uses local provider metadata only." + ) + manifest.script = """ + function evaluate(context) { + if ((context.manifest.providerProfiles || []).length === 0) return []; + return [{ severity: 'low', title: 'Profile backfill', summary: 'profile applied', evidence: [] }]; + } + """ + try db.savePluginManifest(manifest) + let id = try db.insertExchange(exchange(requestSize: 10, headers: [])) + XCTAssertNil(try db.pluginFindings(exchangeIDs: [id], visibleOnly: false)[id]?.first { + $0.pluginID == manifest.id + }) + + db.replaceProviderProfiles([ + ProviderRuntimeProfile(id: UUID(), provider: .custom, endpoint: nil, + configuration: ["mode": "local"], pluginIDs: [manifest.id]), + ]) + XCTAssertEqual(try db.backfillPluginFindings(pluginID: manifest.id), 1) + XCTAssertNotNil(try db.pluginFindings(exchangeIDs: [id], visibleOnly: false)[id]?.first { + $0.pluginID == manifest.id && $0.title == "Profile backfill" + }) + } + func testBrokenJavaScriptPluginFailsOpenAfterExchangeInsertion() throws { let db = try database() var manifest = PluginManifest(id: "thirdparty.broken", name: "Broken", version: "0.1.0", kind: .traffic, diff --git a/FlowlightTests/ProviderProfileTests.swift b/FlowlightTests/ProviderProfileTests.swift new file mode 100644 index 00000000..adbda958 --- /dev/null +++ b/FlowlightTests/ProviderProfileTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import Flowlight + +@MainActor +final class ProviderProfileTests: XCTestCase { + private var defaults: UserDefaults! + private var suite: String! + + override func setUp() { + super.setUp() + suite = "flowlight.provider-profile.tests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suite)! + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suite) + defaults = nil + suite = nil + super.tearDown() + } + + func testStorePersistsVersionedNonSecretProfile() throws { + let store = ProviderProfileStore(defaults: defaults) + let id = UUID() + try store.save(ProviderProfile( + id: id, + name: " Presidio local ", + provider: .presidio, + endpoint: "http://localhost:3000/analyze", + configuration: ["policy": "strict"], + pluginIDs: ["official.presidio", "official.presidio"], + credentialReference: "keychain:flowlight/presidio" + )) + + let reloaded = ProviderProfileStore(defaults: defaults) + XCTAssertEqual(reloaded.profiles.count, 1) + XCTAssertEqual(reloaded.profiles[0].id, id) + XCTAssertEqual(reloaded.profiles[0].name, "Presidio local") + XCTAssertEqual(reloaded.profiles[0].pluginIDs, ["official.presidio"]) + XCTAssertEqual(reloaded.profiles[0].credentialReference, "keychain:flowlight/presidio") + + let raw = try XCTUnwrap(defaults.data(forKey: "inspectionProviderProfiles")) + let text = String(decoding: raw, as: UTF8.self) + XCTAssertTrue(text.contains("\"schemaVersion\":1")) + XCTAssertFalse(text.localizedCaseInsensitiveContains("apiKey")) + XCTAssertFalse(text.localizedCaseInsensitiveContains("password")) + } + + func testRuntimeSnapshotPublishesEnabledCredentialFreeProfilesAfterPersistence() throws { + let store = ProviderProfileStore(defaults: defaults) + var snapshots: [[ProviderRuntimeProfile]] = [] + store.installRuntimeSink { snapshots.append($0) } + XCTAssertEqual(snapshots, [[]]) + + let id = UUID() + var profile = ProviderProfile( + id: id, name: "Local provider", provider: .presidio, + endpoint: "http://localhost:3000/analyze", configuration: ["policy": "strict"], + pluginIDs: ["official.presidio"], credentialReference: "keychain:flowlight/presidio" + ) + try store.save(profile) + + let published = try XCTUnwrap(snapshots.last?.first) + XCTAssertEqual(published.id, id) + XCTAssertEqual(published.provider, .presidio) + XCTAssertEqual(published.endpoint, "http://localhost:3000/analyze") + XCTAssertEqual(published.configuration, ["policy": "strict"]) + XCTAssertEqual(published.pluginIDs, ["official.presidio"]) + + profile.enabled = false + try store.save(profile) + XCTAssertEqual(snapshots.last, []) + + profile.enabled = true + try store.save(profile) + try store.remove(id: id) + XCTAssertEqual(snapshots.last, []) + } + + func testValidationRejectsRemotePlaintextAndEmbeddedCredentials() { + XCTAssertThrowsError(try ProviderProfileValidator.normalized(ProviderProfile( + name: "Remote", provider: .custom, endpoint: "http://provider.example.test/analyze" + ))) { XCTAssertEqual($0 as? ProviderProfileValidationError, .endpoint) } + + XCTAssertThrowsError(try ProviderProfileValidator.normalized(ProviderProfile( + name: "Embedded", provider: .custom, endpoint: "https://user:pass@provider.example.test/analyze" + ))) { XCTAssertEqual($0 as? ProviderProfileValidationError, .endpoint) } + + XCTAssertThrowsError(try ProviderProfileValidator.normalized(ProviderProfile( + name: "Query secret", provider: .custom, + endpoint: "https://provider.example.test/analyze?api_key=sk-live-secret" + ))) { XCTAssertEqual($0 as? ProviderProfileValidationError, .endpoint) } + } + + func testValidationRejectsCredentialValuesAndSecretConfigurationKeys() { + XCTAssertThrowsError(try ProviderProfileValidator.normalized(ProviderProfile( + name: "Raw secret", provider: .bedrock, credentialReference: "sk-live-secret" + ))) { XCTAssertEqual($0 as? ProviderProfileValidationError, .credentialReference) } + + for key in ["api_key", "Authorization", "access-token", "privateKey"] { + XCTAssertThrowsError(try ProviderProfileValidator.normalized(ProviderProfile( + name: "Secret option", provider: .custom, configuration: [key: "not-persisted"] + )), "must reject \(key)") { XCTAssertEqual($0 as? ProviderProfileValidationError, .configuration) } + } + for value in ["sk-live-secret", "Bearer opaque-value", "eyJhbGciOiJIUzI1NiJ9.payload.signature", + String(repeating: "a", count: 40)] { + XCTAssertThrowsError(try ProviderProfileValidator.normalized(ProviderProfile( + name: "Secret value", provider: .custom, configuration: ["account": value] + )), "must reject credential-like value") { + XCTAssertEqual($0 as? ProviderProfileValidationError, .configuration) + } + } + } + + func testHealthReasonsAreDeterministicAndDoNotExposeConfiguration() { + let plugin = PluginManifest( + id: "official.presidio", name: "Presidio", version: "1", kind: .llmMCP, + guardrailProvider: .presidio, description: "PII", privacySummary: "Metadata" + ) + let profile = ProviderProfile( + name: "PII", provider: .presidio, configuration: ["region": "private-value"], + pluginIDs: [plugin.id], credentialReference: "keychain:flowlight/presidio" + ) + + XCTAssertEqual(profile.health(manifests: [plugin]), .ready("1 inspection plugin assigned.")) + let reason = profile.health(manifests: [plugin]).reason + XCTAssertFalse(reason.contains("private-value")) + XCTAssertFalse(reason.contains("keychain:")) + + var disabledPlugin = plugin + disabledPlugin.enabled = false + XCTAssertEqual(profile.health(manifests: [disabledPlugin]), .attention("Every assigned plugin is disabled.")) + XCTAssertEqual(profile.health(manifests: []), .attention("An assigned plugin is no longer installed.")) + } + + func testUnsupportedSchemaFailsClosedWithoutLeakingDocument() { + defaults.set(Data(#"{"schemaVersion":99,"profiles":[]}"#.utf8), forKey: "inspectionProviderProfiles") + let store = ProviderProfileStore(defaults: defaults) + XCTAssertTrue(store.profiles.isEmpty) + XCTAssertEqual(store.loadIssue, "Provider profiles could not be loaded. Create them again in Inspect.") + XCTAssertFalse(store.loadIssue?.contains("99") == true) + } +} diff --git a/FlowlightTests/ResponseTransformTests.swift b/FlowlightTests/ResponseTransformTests.swift index 8510fafc..aff37952 100644 --- a/FlowlightTests/ResponseTransformTests.swift +++ b/FlowlightTests/ResponseTransformTests.swift @@ -78,8 +78,13 @@ final class ResponseTransformRuleTests: XCTestCase { let textRule = ResponseTransformRule(host: "api.example.com", script: "function modifyResponse(args) { return args.responseText.toUpperCase(); }") let textHead = HTTPHead(startLine: "HTTP/1.1 200 OK", headers: [HTTPHeader(name: "Content-Type", value: "text/plain")]) - let text = ResponseScriptRunner.shared.transform(rules: [textRule], request: request, responseHead: textHead, responseBody: Data("ready".utf8)) - XCTAssertEqual(String(decoding: try! XCTUnwrap(text).body, as: UTF8.self), "READY") + let unchanged = ResponseTransformRule(host: "api.example.com", + script: "function modifyResponse(args) { return args.responseText; }") + let text = ResponseScriptRunner.shared.transform(rules: [unchanged, textRule], request: request, + responseHead: textHead, responseBody: Data("ready".utf8)) + let transformed = try! XCTUnwrap(text) + XCTAssertEqual(String(decoding: transformed.body, as: UTF8.self), "READY") + XCTAssertEqual(transformed.rules.map(\.id), [textRule.id]) } func testRunnerWithholdsCredentialsAndFailsOpenForBrokenOrSlowScripts() { diff --git a/FlowlightTests/RewriteRuleTests.swift b/FlowlightTests/RewriteRuleTests.swift index 0e869a5f..c2155b5e 100644 --- a/FlowlightTests/RewriteRuleTests.swift +++ b/FlowlightTests/RewriteRuleTests.swift @@ -180,6 +180,19 @@ final class RewriteRuleTests: XCTestCase { XCTAssertEqual(bodyForm(out)["a"], "2") } + func testOnlyRulesThatActuallyChangeTheRequestAreAttributed() throws { + let noChange = RewriteRule(name: "Missing header", host: "api.example.com", + headers: [HeaderEdit(op: .remove, name: "X-Missing", value: "")]) + let changed = RewriteRule(name: "Add trace", host: "api.example.com", + headers: [HeaderEdit(op: .add, name: "X-Trace", value: "1")]) + let result = try XCTUnwrap(RewriteRules.apply( + [noChange, changed], to: request("POST", "/v1/messages"), host: "api.example.com", + method: "POST", path: "/v1/messages" + )) + XCTAssertEqual(result.rules.map(\.id), [changed.id]) + XCTAssertFalse(result.note.contains("X-Missing")) + } + // MARK: Matching /// A rule for another host, method or path doesn't touch the request. diff --git a/FlowlightTests/RulePackTests.swift b/FlowlightTests/RulePackTests.swift index 8f6ffb42..7e9698f3 100644 --- a/FlowlightTests/RulePackTests.swift +++ b/FlowlightTests/RulePackTests.swift @@ -223,6 +223,15 @@ final class RulePackTests: XCTestCase { policies: ["claude": policy], flows: [ad]) XCTAssertTrue(policyAlreadyBlocks.isEmpty) + var aliasAd = ad + aliasAd.agentKey = "claude-code" + let aliasPolicyAlreadyBlocks = RulePackSimulation.run( + candidate: candidate, definitions: [definition], configurations: [], overrides: [], rules: [], + policies: ["claude": policy], flows: [aliasAd] + ) + XCTAssertTrue(aliasPolicyAlreadyBlocks.isEmpty, + "simulation must use the same catalog aliases as live policy enforcement") + let explicitAllow = Rule(action: .allow, app: "curl", destination: "ads.example") let userAllowWins = RulePackSimulation.run(candidate: candidate, definitions: [definition], configurations: [], overrides: [], rules: [explicitAllow], diff --git a/FlowlightTests/VisualizeNavigationTests.swift b/FlowlightTests/VisualizeNavigationTests.swift index ff99d071..22cade8a 100644 --- a/FlowlightTests/VisualizeNavigationTests.swift +++ b/FlowlightTests/VisualizeNavigationTests.swift @@ -43,31 +43,36 @@ final class VisualizeNavigationTests: XCTestCase { XCTAssertTrue(SidebarItem.sidebarOrder.allSatisfy { $0.shortcut != nil }) } - func testSourcesCoverageDistinguishesNoEvidenceFromZeroPercentNamed() { - XCTAssertEqual(SourceSetupPolicy.coverageHealth(named: 0, hasTraffic: false), .empty) - XCTAssertEqual(SourceSetupPolicy.coverageHealth(named: 0, hasTraffic: true), .warning) - XCTAssertEqual(SourceSetupPolicy.coverageHealth(named: 0.8, hasTraffic: true), .warning) - XCTAssertEqual(SourceSetupPolicy.coverageHealth(named: 0.81, hasTraffic: true), .good) + func testSourcesRestoresExplicitAndLegacySectionTargets() { + XCTAssertEqual(SourcesTab.restored(explicitRawValue: "coverage", legacyScreenRawValue: "devices"), + .coverage, "the modern section argument must win") + XCTAssertEqual(SourcesTab.restored(explicitRawValue: nil, legacyScreenRawValue: "devices"), .devices) + XCTAssertEqual(SourcesTab.restored(explicitRawValue: nil, legacyScreenRawValue: "coverage"), .coverage) + XCTAssertEqual(SourcesTab.restored(explicitRawValue: "unknown", legacyScreenRawValue: "capture"), + .collector) } - func testSourcesRecoveryChecklistRequiresAConfiguredCollectorAndArrivingData() { - XCTAssertTrue(SourceSetupPolicy.collectorConfigured(mode: .nettop, extensionState: .notInstalled, - extensionFellBack: false), - "the sampler needs no separate filter setup") - XCTAssertFalse(SourceSetupPolicy.collectorConfigured(mode: .networkExtension, - extensionState: .notInstalled, - extensionFellBack: false)) - XCTAssertTrue(SourceSetupPolicy.collectorConfigured(mode: .networkExtension, - extensionState: .enabled, - extensionFellBack: false)) - XCTAssertFalse(SourceSetupPolicy.collectorConfigured(mode: .networkExtension, - extensionState: .enabled, - extensionFellBack: true), - "fallback is working but is not the configured collector") - XCTAssertFalse(SourceSetupPolicy.setupComplete(mode: .nettop, extensionState: .unknown, - extensionFellBack: false, isReceiving: false)) - XCTAssertTrue(SourceSetupPolicy.setupComplete(mode: .nettop, extensionState: .unknown, - extensionFellBack: false, isReceiving: true)) + func testSourcesSectionsHaveStableDistinctScrollTargets() { + XCTAssertEqual(SourcesTab.allCases, [.collector, .devices, .coverage]) + XCTAssertEqual(SourcesTab.allCases.map(\.anchorID), + ["sources-collector", "sources-devices", "sources-coverage"]) + XCTAssertEqual(Set(SourcesTab.allCases.map(\.anchorID)).count, SourcesTab.allCases.count) + } + + func testRepeatedSourcesLinkCreatesANewSectionFocusRequest() { + let nav = AppNavigation() + let initialRequest = nav.sourcesRequestID + + nav.showSources(.devices) + let firstRequest = nav.sourcesRequestID + XCTAssertEqual(nav.selection, .sources) + XCTAssertEqual(nav.sourcesTab, .devices) + XCTAssertNotEqual(firstRequest, initialRequest) + + nav.showSources(.devices) + XCTAssertEqual(nav.sourcesTab, .devices) + XCTAssertNotEqual(nav.sourcesRequestID, firstRequest, + "a repeated link must scroll even when its section value did not change") } func testFocusNoticeClaimsFilteringOnlyWhereTheWholeScopeApplies() { @@ -94,6 +99,12 @@ final class VisualizeNavigationTests: XCTestCase { XCTAssertEqual(SidebarItem.visualize.englishTitle, "Connections") } + func testPoliciesKeepsTheCompatibleRulesRouteButUsesCurrentHelpAnchor() { + XCTAssertEqual(SidebarItem.rules.rawValue, "rules") + XCTAssertEqual(SidebarItem.rules.helpAnchor, "policies") + XCTAssertEqual(SidebarItem.rules.englishTitle, "Policies") + } + func testConnectionsLoadingAlwaysOutranksAStaleSnapshotAndYieldsToEmptyState() { XCTAssertTrue(ConnectionsRenderPolicy.showsLoading(isLoading: true, hasSnapshot: true)) XCTAssertTrue(ConnectionsRenderPolicy.showsLoading(isLoading: true, hasSnapshot: false)) @@ -170,7 +181,8 @@ final class VisualizeNavigationTests: XCTestCase { nav.showPackReviews() XCTAssertEqual(nav.selection, .rules) - XCTAssertEqual(nav.rulesTab, .packs) + XCTAssertEqual(nav.rulesTab, .rules) + XCTAssertEqual(nav.policyRequest?.destination, .managedPack(nil)) XCTAssertNil( nav.backDestination, "pack review navigation starts a new task and must not retain an unrelated investigation trail" diff --git a/FlowlightTests/WindowVisibilityTests.swift b/FlowlightTests/WindowVisibilityTests.swift index ac9588d4..2ed414cb 100644 --- a/FlowlightTests/WindowVisibilityTests.swift +++ b/FlowlightTests/WindowVisibilityTests.swift @@ -1,3 +1,4 @@ +import Combine import XCTest @testable import Flowlight @@ -32,6 +33,24 @@ final class WindowVisibilityTests: XCTestCase { XCTAssertTrue(TrafficMonitor.needsLiveUI(windowVisible: false, demoEnabled: true, forceRender: true)) } + @MainActor + func testLiveTicksDoNotInvalidateTheWholeTrafficMonitor() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("flowlight-live-state-\(UUID()).sqlite") + let monitor = TrafficMonitor(databaseURL: url) + var monitorChanges = 0 + var liveChanges = 0 + let monitorObservation = monitor.objectWillChange.sink { monitorChanges += 1 } + let liveObservation = monitor.liveState.objectWillChange.sink { liveChanges += 1 } + + monitor.liveState.publish(LiveSnapshot(currentIn: 128, isReceiving: true)) + + XCTAssertEqual(liveChanges, 1) + XCTAssertEqual(monitorChanges, 0, + "one-second rate updates must not rebuild every screen that observes TrafficMonitor") + withExtendedLifetime((monitorObservation, liveObservation)) {} + } + /// AppKit posts occlusion changes freely, including ones that repeat the current state. A counter drifted /// here; identity cannot. func testRepeatedUpdatesForOneWindowDoNotDrift() { diff --git a/README.md b/README.md index 8aa6a92b..1a35cda6 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,12 @@ traffic, DNS, essential Apple system services and Flowlight's own connections ar after the app stops running. When HTTPS inspection proxies an app, destination rules and enabled managed packs are decided against the original app before Flowlight opens the origin connection; agent allowlists still apply only on the direct Network Extension path. -### Rules -An allowlist is one agent's idea of where it may go. A **rule** is the general version: it names a subject — an app, -an agent, a destination, a URL, or a pairing of two of those — and says block or allow. Right-click any row in Live, -History, AI Agents or Inspect to write one about what you're looking at; all of them land in the same editor on the -**Rules** screen. +### Policies +**Policies** is the inventory for every control Flowlight can apply: ordinary allow/block rules, agent allowlists, +managed packs, and—where HTTPS inspection is available—guardrails, mocks, request rewrites, and response transforms. +Each row reports whether it is actually enforcing, reporting, watching, waiting, scheduled, paused, or unavailable, and +opens the contextual editor that owns it. An allowlist is one agent's idea of where it may go; an ordinary **rule** is +the general version, naming an app, agent, destination, URL, or pairing and saying block or allow. - **The most specific match decides**, and a tie goes to the block. An exception written underneath a broad block works; one broader than what it excepts doesn't quietly widen it. @@ -148,15 +149,17 @@ History, AI Agents or Inspect to write one about what you're looking at; all of - **A rule says where it bites.** The extension refuses whole connections and knows nothing about paths; HTTPS inspection refuses one request and answers with a status the agent can read; the sampler refuses nothing. A rule the current setup can't carry out says so in the list instead of looking enforced. -- A **violations feed** beside the list: every connection a rule decided, refusals and exceptions alike. +- A unified read-only **Activity** feed beside the inventory: rule decisions, allowlist violations, managed-pack events, + and distinct inspection-policy effects without pretending different policy types share one persistence model. ### Managed ad and tracker packs Flowlight includes three official hostname lists: **Advertising**, **Cross-site tracking**, and **Privacy and telemetry**. Their baseline is built into the signed app for offline use and the app checks one fixed HTTPS source for newer -publisher-signed catalogs. Automatic checks are on by default and can be switched off in **Rules › Packs**; no traffic, -pack settings, or device identifier is sent. The server sees the Mac's IP address and `Flowlight/` user agent. +publisher-signed catalogs. Automatic checks are on by default and can be switched off under **Policies › Managed packs**; +no traffic, pack settings, or device identifier is sent. The server sees the Mac's IP address and `Flowlight/` +user agent. -All three packs are visible in **Rules › Packs** and off by default. A valid signature authenticates content but never +All three packs are visible under **Policies › Managed packs** and off by default. A valid signature authenticates content but never grants enforcement consent: new packs remain off, and changed destinations disable that pack until you review the exact new version and press Save. A verified accepted catalog remains available as visible stale last-known-good during an outage; rollback, equivocation, revoked trust, or corrupt cached bytes fail closed. Enable a pack for every app or only @@ -217,11 +220,13 @@ Flowlight says *Extension connection invalidated*, and neither changes its mind. Flowlight climbs out of that on its own: redial twice, then ask macOS which build it has and reinstall the extension if it isn't this app's, then redial again — and if none of it works, capture with the nettop sampler so there is still data. It says which rung it is on and how long until the next one. Your chosen source isn't -rewritten, blocking is switched off while the sampler stands in (the sampler can't refuse anything, and Rules +rewritten, blocking is switched off while the sampler stands in (the sampler can't refuse anything, and Policies shouldn't claim otherwise), and **Try the extension again** starts over. -### Sources: collector, coverage and devices -**Sources** keeps collector health and recovery, hostname coverage, and opt-in device channels in one destination. Its health summary shows whether traffic is arriving, how much is named, and which device channels are active. +### Sources: collector, devices and coverage +**Sources** is one scrollable operational page: Collector first, then Devices and Coverage as clearly headed sections. +Status notices, Ask answers, restored windows, and screenshot links scroll to and briefly focus the relevant section +without hiding the others. A Mac sends and receives over more than TCP and UDP. **Sources › Devices** covers the non-network channels, with every channel off until you turn it on because watching more of your Mac should be a decision rather than something an update did. @@ -317,7 +322,7 @@ No account, no cloud, no telemetry. Everything stays in a local SQLite database. answers and TLS ClientHellos (a kernel filter drops everything else), and **packet contents are never stored**. Naming who owns an IP with no known hostname is **on by default**: it sends those public IPs (never private ones) to Team Cymru's DNS service, and you can turn it off in Sources › Collector. Release and official rule-pack checks make standard HTTPS -requests with no traffic or pack settings; each has its own off switch in Settings or Rules › Packs. To attribute tools +requests with no traffic or pack settings; each has its own off switch in Settings or Policies › Managed packs. To attribute tools and MCP servers, Flowlight reads process command lines and your agents' MCP config files on your Mac; none of it leaves your Mac. HTTPS inspection is off unless you turn it on, and what it records stays in the same local database. @@ -484,7 +489,7 @@ Shipped: - [x] AI visualization: a separate mode for proved agent clients, attributed tools and MCP servers, LLM providers, and every other destination they reached (0.16.0). Tool traffic folds into its parent agent without erasing the - selectable tool node; provider grouping keeps exact hosts underneath for honest Reports, Inspect and Rules + selectable tool node; provider grouping keeps exact hosts underneath for honest History, Inspect and Policies navigation. Process lineage is labelled “works for,” never an invocation count without inspected evidence. The full evidence, data, status and acceptance contract lives in [docs/VISUALIZATION.md](docs/VISUALIZATION.md). @@ -526,20 +531,23 @@ Releases are deliberately not ordinary merges. `main` represents published softw new version and generated site until a downloadable, verified build exists. Every release follows this order: ```text -feature branch → release branch → reviewed green PR → immutable tag → signed dry run → publish and verify → merge to main +feature branch → release branch → reviewed green PR → immutable tag → signed preparation → publish exact bytes and verify → merge to main ``` 1. Branch a feature from `main`, test it, and rebase it onto current `main` rather than merging `main` into it. 2. Fast-forward finished features into `release/` and make one final release commit containing the version bump, release entry in `site/pages/releases.html`, and regenerated `docs/` website output. -3. Push the release branch and open a PR to `main`. Do not tag until it has been reviewed and every required check is - green. -4. Create and push an annotated `v` tag on that exact release commit. Tags are immutable: never retag, move or +3. Push the release branch and open a PR to `main`. After reviewing the final head, a login configured in the + `RELEASE_REVIEWERS` repository variable must post `Reviewed-Commit: ` as a PR comment. The editable PR + body is not review evidence. Do not tag until that authenticated receipt and every required check are green. +4. The designated release account creates and pushes an annotated `v` tag on that exact release commit; the + active ruleset rejects release-tag creation by other write collaborators. Tags are immutable: never retag, move, or force-push one. If it is wrong, issue the next patch release instead. -5. The tag push automatically runs the signed/notarized/Gatekeeper **dry run** and cannot publish. After it passes, - explicitly dispatch production for that same tag. -6. Verify the GitHub release and its DMG, PKG and `SHA256SUMS.txt` assets/checksums. Only then merge the release PR into - `main`, allowing GitHub Pages to advertise the downloadable release. +5. The tag push builds, signs, notarizes, Gatekeeper-verifies, attests, and retains one immutable preparation artifact; + it cannot publish. After it passes, explicitly dispatch publication with that preparation run ID. +6. Publication verifies the run, archive digest, manifest, checksums, and provenance, then creates and re-downloads a draft + before making those exact bytes public. Verify the DMG, PKG, `SHA256SUMS.txt`, and `RELEASE-MANIFEST.json`; only then + merge the release PR into `main`, allowing GitHub Pages to advertise the downloadable release. The full commands, signing prerequisites and CI behavior live in [CONTRIBUTING.md](CONTRIBUTING.md#branches-and-releases) and [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#release-branches). diff --git a/SECURITY.md b/SECURITY.md index 8c5b37aa..0811f964 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -44,7 +44,17 @@ what Flowlight does defend against. ## Signed releases -Releases are signed with a Developer ID and notarized by Apple, and every release publishes `SHA256SUMS.txt`. +Releases are signed with a Developer ID and notarized by Apple, and every release publishes `SHA256SUMS.txt` plus +`RELEASE-MANIFEST.json`. A tag-push preparation builds and signs once, passes Gatekeeper, attests the DMG, PKG, +checksums, notes, and manifest, and retains those exact bytes. The separate publication job has no signing or Apple +credentials: it verifies the selected run, source tag/SHA, archive digest, manifest, checksums, and GitHub provenance, +then re-downloads a draft before making those same bytes public. For example: + +```sh +gh attestation verify Flowlight.dmg --repo xinbetween/flowlight \ + --signer-workflow xinbetween/flowlight/.github/workflows/release.yml +``` + The in-app updater refuses a download whose checksum it cannot verify, and refuses to install an app that is not signed by the same team as the copy asking for the update. diff --git a/Shared/AgentCatalog.swift b/Shared/AgentCatalog.swift index b5e9cd0f..2352519f 100644 --- a/Shared/AgentCatalog.swift +++ b/Shared/AgentCatalog.swift @@ -113,8 +113,49 @@ enum AgentCatalog { ] static func knownAgent(bundleID: String, appName: String) -> KnownAgent? { - let name = appName.lowercased() - return agents.first { $0.bundleIDs.contains(bundleID) || $0.processNames.contains(name) || $0.processNames.contains(bundleID.lowercased()) } + knownAgent(identity: bundleID) ?? knownAgent(identity: appName) + } + + /// Resolves every stable spelling Flowlight uses for a catalogued agent: display name, bundle identifier, or + /// process name. Keeping this in the shared catalog lets policy, rule, guardrail, and UI scopes agree that, for + /// example, `claude` and `claude-code` are the same agent without flattening unrelated discovered clients. + static func knownAgent(identity value: String) -> KnownAgent? { + comparisonAgent(identity: value) + } + + static func sameAgentIdentity(_ lhs: String, _ rhs: String) -> Bool { + lhs == rhs || identityComparisonKey(lhs) == identityComparisonKey(rhs) + } + + /// One matching key for policy, inventory, routing, and alert joins. Exact catalog identifiers and display labels + /// retain their case-sensitive meaning (`Claude` desktop versus the `claude` CLI). A case-insensitive spelling is + /// accepted only when it identifies one catalog product; uncatalogued identities retain their historic + /// case-insensitive behavior. + static func identityComparisonKey(_ value: String) -> String { + if let agent = comparisonAgent(identity: value) { return "known:\(agent.name)" } + let matches = caseInsensitiveAgents(identity: value) + if !matches.isEmpty { return "ambiguous:\(value)" } + return "custom:\(value.lowercased())" + } + + private static func comparisonAgent(identity value: String) -> KnownAgent? { + if let identified = agents.first(where: { agent in + agent.bundleIDs.contains(value) || agent.processNames.contains(value) + }) { + return identified + } + if let named = agents.first(where: { $0.name == value }) { return named } + let matches = caseInsensitiveAgents(identity: value) + return matches.count == 1 ? matches[0] : nil + } + + private static func caseInsensitiveAgents(identity value: String) -> [KnownAgent] { + let identity = value.lowercased() + return agents.filter { agent in + agent.name.lowercased() == identity + || agent.bundleIDs.contains(where: { $0.lowercased() == identity }) + || agent.processNames.contains(where: { $0.lowercased() == identity }) + } } /// The LLM provider behind a destination, if any. diff --git a/Shared/AgentPolicy.swift b/Shared/AgentPolicy.swift index 45841e75..ac0ec836 100644 --- a/Shared/AgentPolicy.swift +++ b/Shared/AgentPolicy.swift @@ -32,6 +32,26 @@ struct AgentPolicy: Codable, Equatable, Sendable { Preset(name: "Apple", patterns: ["apple.com", "icloud.com", "mzstatic.com"]), ] + /// Select one policy for an identity without depending on Dictionary iteration order. Exact identities win in + /// caller-provided order; catalog aliases are a fallback with a stable agent-ID tie-break for legacy duplicates. + static func resolve(_ policies: S, identities: [String]) -> AgentPolicy? where S.Element == AgentPolicy { + let candidates = Array(policies) + for identity in identities { + if let exact = candidates + .filter({ $0.agentID == identity }) + .sorted(by: { $0.agentID < $1.agentID }) + .first { + return exact + } + } + return candidates.filter { policy in + identities.contains { AgentCatalog.sameAgentIdentity(policy.agentID, $0) } + }.sorted { + let lhs = $0.agentID.lowercased(), rhs = $1.agentID.lowercased() + return lhs == rhs ? $0.agentID < $1.agentID : lhs < rhs + }.first + } + /// Whether this destination is allowed. Local-network traffic always is. func allows(host: String, ip: String, isAIProvider: Bool) -> Bool { if NetworkScope.isLocalNetwork(ip) { return true } diff --git a/Shared/Guardrail.swift b/Shared/Guardrail.swift index 14e0b41c..a20e72e7 100644 --- a/Shared/Guardrail.swift +++ b/Shared/Guardrail.swift @@ -44,7 +44,8 @@ struct Guardrail: Codable, Identifiable, Equatable, Sendable { /// names are written by hand in one place and generated in another. func refuses(agent key: String, server name: String?, tool called: String) -> Bool { guard enabled, isComplete else { return false } - if !agent.isEmpty, !GlobMatch.glob(agent.lowercased(), key.lowercased()), agent.lowercased() != key.lowercased() { + if !agent.isEmpty, !GlobMatch.glob(agent.lowercased(), key.lowercased()), + !AgentCatalog.sameAgentIdentity(agent, key) { return false } if !server.isEmpty { @@ -59,7 +60,7 @@ struct Guardrail: Codable, Identifiable, Equatable, Sendable { func refuses(agent key: String, resource uri: String) -> Bool { guard enabled, !resource.isEmpty else { return false } - if !agent.isEmpty, agent.lowercased() != key.lowercased() { return false } + if !agent.isEmpty, !AgentCatalog.sameAgentIdentity(agent, key) { return false } let wanted = resource.lowercased(), got = uri.lowercased() return wanted == got || GlobMatch.glob(wanted, got) } @@ -107,6 +108,8 @@ enum GuardrailBook { /// Whether any guardrail could apply to this agent at all. Asked before a request body is parsed, so an agent /// nobody has written a guardrail about never pays for the feature. static func any(_ guardrails: [Guardrail], agent: String) -> Bool { - guardrails.contains { $0.enabled && $0.isComplete && ($0.agent.isEmpty || $0.agent.lowercased() == agent.lowercased()) } + guardrails.contains { + $0.enabled && $0.isComplete && ($0.agent.isEmpty || AgentCatalog.sameAgentIdentity($0.agent, agent)) + } } } diff --git a/Shared/Rule.swift b/Shared/Rule.swift index 3b0c4c16..42f7e361 100644 --- a/Shared/Rule.swift +++ b/Shared/Rule.swift @@ -66,6 +66,9 @@ struct Rule: Codable, Identifiable, Equatable, Sendable { case flow /// The HTTPS inspection proxy, which refuses one request and answers with a status. case request + /// A guardrail changed or refused one inspected agent request. Persisted separately so deleting the guardrail + /// cannot relabel its historical activity as an ordinary network rule. + case guardrail } var engine: Engine { path.isEmpty && method.isEmpty ? .flow : .request } @@ -302,6 +305,8 @@ extension Rule { let p = pattern.lowercased() guard !p.isEmpty else { return true } return p == bundleID.lowercased() || p == agentKey.lowercased() + || AgentCatalog.sameAgentIdentity(pattern, bundleID) + || AgentCatalog.sameAgentIdentity(pattern, agentKey) } } @@ -385,7 +390,7 @@ extension Rule { switch engine { case .flow: return extensionRunning ? nil : "Watching only — the Network Extension refuses connections, and it isn't running." - case .request: + case .request, .guardrail: return inspecting ? nil : "Watching only — a path can only be matched by HTTPS inspection, and it is off." } } diff --git a/Shared/RulePackSimulation.swift b/Shared/RulePackSimulation.swift index b550f817..dd5e9904 100644 --- a/Shared/RulePackSimulation.swift +++ b/Shared/RulePackSimulation.swift @@ -50,7 +50,10 @@ enum RulePackSimulation { let user = RuleBook.decide(flow.facts, rules: rules, now: flow.when, session: session) if user.rule != nil { return user.verdict == .block } if matcher?.matches(host: flow.host, bundleID: flow.bundleID).isEmpty == false { return true } - guard let policy = policies[flow.agentKey] ?? policies[flow.bundleID] else { return false } + guard let policy = AgentPolicy.resolve( + policies.values, + identities: [flow.agentKey, flow.bundleID] + ) else { return false } return BlockRules.verdict(for: flow.facts, policy: policy) == .block } diff --git a/docs/404.html b/docs/404.html index 0d6bebcd..6155546d 100644 --- a/docs/404.html +++ b/docs/404.html @@ -86,7 +86,7 @@

That page isn't here

Try the home page,

diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7d39f944..e655bcef 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -400,7 +400,7 @@ TEAM_ID=ABCDE12345 \ DMG_SIGN_IDENTITY="Developer ID Application: Your Name (ABCDE12345)" \ NOTARY_PROFILE=flowlight-notary scripts/build-dmg.sh # build/Flowlight.dmg, signed + notarized INSTALLER_IDENTITY="Developer ID Installer: Your Name (ABCDE12345)" \ -NOTARY_PROFILE=flowlight-notary TEAM_ID=ABCDE12345 scripts/build-pkg.sh +NOTARY_PROFILE=flowlight-notary TEAM_ID=ABCDE12345 REUSE_EXPORT=1 scripts/build-pkg.sh gh release create v0.1.0 build/Flowlight.dmg build/Flowlight-0.1.0.pkg --title "Flowlight 0.1.0" --generate-notes scripts/update-cask.sh # Homebrew cask → new version + checksum git -C "$(brew --repository xinbetween/tap)" push # publishes it @@ -408,8 +408,9 @@ git -C "$(brew --repository xinbetween/tap)" push # publishes it - Keep the DMG asset named exactly `Flowlight.dmg`: the website and README link to `releases/latest/download/Flowlight.dmg`, and so does the Homebrew cask. -- `scripts/update-cask.sh` runs after the release exists on GitHub: with no local build it checksums the published - DMG. It lints the cask with `brew style` and `brew audit` in the tap, since Homebrew won't lint one outside a tap. +- `scripts/update-cask.sh` prefers the local `build/Flowlight.dmg`; with no local build it can checksum the published + DMG. CI sets `REQUIRE_LOCAL_DMG=1`, so publication cannot silently replace its verified prepared DMG with a download. + It lints the cask with `brew style` and `brew audit` in the tap, since Homebrew won't lint one outside a tap. - `INSTALLER_IDENTITY` is what signs the `.pkg`. Leave it out and `build-pkg.sh` produces an unsigned package that Gatekeeper refuses, without saying so. - Without `DMG_SIGN_IDENTITY` and `NOTARY_PROFILE` the DMG contains an ad-hoc signed app. That's fine for testing, @@ -434,7 +435,7 @@ cannot be downloaded until signing and notarization finish. `docs/` also has to commit (CI checks it) and `build_site.py` reads `MARKETING_VERSION`, so the version bump and the rebuilt site cannot be separated. Keeping both off `main` until the release exists is what the branch is for. -Every release follows the mandatory sequence **feature branch → release branch → reviewed green PR → immutable tag → signed dry run → publish and verify → merge to main**: +Every release follows the mandatory sequence **feature branch → release branch → reviewed green PR → immutable tag → signed preparation → publish exact bytes and verify → merge to main**: ```sh git switch -c feature/relaunch-through-proxy main @@ -446,13 +447,15 @@ git merge --ff-only feature/relaunch-through-proxy git push -u origin release/0.13.4 gh pr create --base main --title "Flowlight 0.13.4" -# wait for review and every required PR check to pass +# after reviewing the final head, a configured trusted reviewer posts this authenticated receipt: +gh pr comment --body "Reviewed-Commit: $(git rev-parse HEAD)" +# wait for that receipt and every required PR check to pass git tag -a v0.13.4 -m "Flowlight 0.13.4" -git push origin v0.13.4 # automatically runs the signed dry run; it cannot publish -# inspect that successful signed/notarized/Gatekeeper run, then publish from the same tag: -gh workflow run Release --ref v0.13.4 -f tag=v0.13.4 -# verify the GitHub release, DMG, PKG and SHA256SUMS.txt before the final merge +git push origin v0.13.4 # prepares, attests and retains the only bytes publication may use +# inspect that successful preparation, then publish its exact artifact from the same tag: +gh workflow run Release --ref v0.13.4 -f prepare_run_id= +# verify DMG, PKG, SHA256SUMS.txt and RELEASE-MANIFEST.json before the final merge gh pr merge --merge --delete-branch ``` @@ -461,17 +464,29 @@ comes after publication so the site can only ever describe something downloadabl force-push or move a pushed version. If a tag is wrong or a release needs another change, bump the patch version and begin a new release branch and tag. -A tag push starts `release.yml` automatically in dry-run-only mode; push-triggered runs cannot publish. It first -requires an open or merged `main` pull request and the latest successful CI and CodeQL checks at the exact tagged -commit, rather than running the same test suite a second time. It must then build, sign, notarize and pass Gatekeeper -verification before an explicit production dispatch for the immutable tag. After production succeeds, verify the -GitHub release page and downloaded DMG, PKG and `SHA256SUMS.txt` checksums before merging the release PR. +A tag push starts `release.yml` automatically in preparation-only mode; push-triggered runs cannot publish. It first +requires an open or merged `main` pull request and an exact-SHA `Reviewed-Commit: ` comment posted by a +login configured in the `RELEASE_REVIEWERS` repository variable. The commenter must be an owner, member, or +collaborator; the editable PR body is never accepted as review evidence. The release manifest retains the authenticated +comment ID, URL, reviewer, repository association, PR number, and reviewed SHA. Preparation also requires the latest +successful CI and CodeQL checks from their trusted workflows at that exact tagged commit. Every Actions run must also +identify the same authenticated, exact-head `main` PR; checks from another PR at the same commit are rejected. It then +builds, signs, notarizes, passes Gatekeeper, +records checksums and a source/review manifest, attests all five prepared files, and retains them for 90 days in one +immutable Actions artifact. An explicit publication dispatch names that run ID. Its separate job has no certificates, +provisioning profiles, Xcode, or notarization credentials: it verifies and publishes the retained bytes only. After +publication succeeds, verify the GitHub release page and downloaded DMG, PKG, `SHA256SUMS.txt`, and +`RELEASE-MANIFEST.json` before merging the release PR. ## Releasing from CI `.github/workflows/release.yml` checks the tag matches `MARKETING_VERSION`, verifies the exact commit already passed -the required PR CI and CodeQL checks, then signs, notarizes and verifies with `spctl`. A tag push always stops there; -an explicit dispatch for that tag publishes the release and updates the Homebrew cask. +the required PR CI and CodeQL checks, then signs, notarizes, verifies with `spctl`, creates the release manifest and +notes, attests every file, and uploads one immutable artifact. A tag push always stops there. An explicit dispatch for +that tag and preparation run ID verifies the run metadata, archive digest, manifest, checksums, and attestations; creates +or safely resumes a draft from those exact files; re-downloads and compares every release asset and the release body; +then publishes. If `TAP_TOKEN` is configured, Homebrew is updated idempotently from the already verified DMG; otherwise +GitHub publication succeeds and the cask is updated manually. Pull-request verification jobs explicitly check out the PR head SHA associated with their checks, not GitHub's synthetic merge ref. App-changing PRs also receive one required prospective-merge build of the synthetic tree, so exact @@ -485,22 +500,26 @@ requires every applicable job and preserves an immutable shadow receipt containi fingerprint, workflow schema, and conclusions. A cache is never release evidence. ```sh -scripts/ci-secrets.sh # upload the secrets once -git tag -a v0.3.0 -m "Flowlight 0.3.0" && git push origin v0.3.0 # automatic dry run -gh workflow run Release --ref v0.3.0 -f tag=v0.3.0 # after it passes: publish +scripts/ci-secrets.sh # upload preparation secrets once +git tag -a v0.3.0 -m "Flowlight 0.3.0" && git push origin v0.3.0 # automatic preparation +gh workflow run Release --ref v0.3.0 -f prepare_run_id= ``` `--ref` has to name a tag whose tree *contains* the workflow: `gh` reads the workflow file from the ref you give it, so tags older than `.github/workflows/release.yml` fail with "Workflow does not have 'workflow_dispatch' -trigger". The environment only accepts `v*` tags, so dispatching from a branch is refused at the approval gate — -which is the point of it. +trigger". Both environments accept only `v*` tags, so branch dispatches cannot reach signing or publication credentials. -A tag push—and an explicit dispatch with **dry_run=true**—builds, signs, notarizes and asks Gatekeeper for a verdict, -then stops without creating a release or touching the cask. Production requires an explicit dispatch with the tag input -and **dry_run=false**. +A tag push performs the only build/sign/notarize pass and stops after preserving its attested preparation. Production +requires an explicit dispatch with that successful push run's numeric ID. `scripts/release_artifact.py` rejects a run +from another event, repository, workflow, tag, SHA, or conclusion; an expired, missing, or duplicate artifact; changed +bytes; malformed checksums; manifest disagreement; and unexpected files. Its tested archive extractor also rejects +unsafe paths, duplicate names, non-regular entries, substituted package names, corruption, and oversized expansion before +writing prepared files. Publication verifies GitHub provenance for every file and publishes only after re-downloading and +comparing the draft successfully. A rerun resumes a draft, or verifies an already +public immutable release without mutating its assets, so a transient Homebrew failure does not require another signature. Release notes come from `packaging/release-notes/.md` when that file exists (checksums are appended), and -from generated notes when it doesn't. +from the matching generated site release entry when it does not. A release with neither source fails preparation. `scripts/ci-secrets.sh` finds the two provisioning profiles by name in Xcode's folder, so the only things it asks for are the certificate exports and how to notarize. Prefer the App Store Connect key where you can: it can only @@ -518,33 +537,47 @@ notarize, so revoking it breaks nothing else, whereas an app-specific password a | `NOTARY_APPLE_ID`, `NOTARY_PASSWORD`, `NOTARY_TEAM_ID` | *the other way:* an Apple ID and an app-specific password | | `TEAM_ID` | e.g. `38RJUJHKZS` | | `DMG_SIGN_IDENTITY`, `INSTALLER_IDENTITY` | the identity names, e.g. `Developer ID Application: Your Name (TEAMID)` | -| `TAP_TOKEN` | *optional.* Fine-grained token with Contents: write on `xinbetween/homebrew-tap`, so the cask updates itself | + +All values above belong only to the `release` preparation environment. `TAP_TOKEN` is the sole secret in the separate +`release-publish` environment: an optional fine-grained token with Contents: write on `xinbetween/homebrew-tap`. Never +put certificates or notarization credentials in `release-publish`, and never put the tap token in `release`. ### What protects it -A signing identity in GitHub Actions is a signing identity outside your Mac, so the release path is fenced in four -ways. Each one is a setting on the repository; they aren't in this file, so this is also the record of what to -check if the setup is ever rebuilt. - -1. **The secrets live in the `release` environment, not the repository.** Only a job that declares - `environment: release` can read them. A workflow added to a branch cannot. -2. **Only listed accounts can start a release.** The environment no longer waits for a click — a release by the - maintainer shouldn't need the maintainer to approve themselves. In its place the job refuses to run unless - `github.actor` is in `RELEASE_ACTORS` (a repository variable, `blessdyb` by default), so a tag pushed by - anyone else fails instead of producing a signed build. This is weaker than an approval: it trusts the account - rather than a person at the keyboard, so it stands on the account's own protection. Put the reviewers back - (Settings › Environments › release) if that trade stops being the right one. -3. **The environment only accepts `v*` tags.** Its deployment branch policy is a single tag rule, so a run from a - branch — or from a tag with any other name — can't reach the secrets even with approval. -4. **Release tags can't move.** A ruleset over `refs/tags/v*` blocks deletion, updates and force-pushes, so a - published tag always points at the commit that was reviewed. `main` is likewise protected from force-pushes +A signing identity in GitHub Actions is a signing identity outside your Mac, so the release path is fenced in six +ways. The repository settings among them are not stored in this file, so this is also the record of what to check if the +setup is ever rebuilt. + +1. **Signing secrets live only in `release`; publication credentials live only in `release-publish`.** The preparation + job has read-only Contents permission and cannot create a release. The publication job has no signing or Apple input, + and its static contract rejects any build, signing, provisioning, or notarization command. +2. **Only listed accounts can start or rerun a release.** The environments do not need a self-approval click — a release + by the maintainer should not require the maintainer to approve themselves. In its place both jobs require + `github.actor` and `github.triggering_actor` to appear in `RELEASE_ACTORS` (a repository variable, `blessdyb` by + default). This also prevents an unlisted account from inheriting the original actor's authority by rerunning a job. + This is weaker than an approval: it trusts the allowed account rather than a person at the keyboard, so it stands on + that account's own protection. Put the reviewers back in Settings › Environments if that trade stops being right. +3. **A trusted account authenticates the exact-head review receipt.** `RELEASE_REVIEWERS` is a repository variable + containing comma- or whitespace-separated GitHub logins. One of those logins, with owner/member/collaborator + association to this repository, must post the exact `Reviewed-Commit: ` line on the associated PR. Preparation + reads authenticated issue-comment metadata and retains its immutable ID and URL; a contributor-controlled PR body or + comment from any other account cannot satisfy this gate. +4. **Both environments accept only `v*` tags.** Each deployment branch policy is a single tag rule, so a run from a + branch — or from a tag with any other name — cannot reach either credential set. +5. **Only the designated release account can create release tags, and release tags can't move.** The active ruleset + over `refs/tags/v*` restricts creation with an always-bypass entry for the release account, then blocks deletion, + updates, and force-pushes for everyone. A collaborator therefore cannot tag attacker-controlled workflow content, + and a published tag always points at the commit that was reviewed. `main` is likewise protected from force-pushes and deletion. +6. **Published releases are immutable.** Repository immutable releases are enabled. Draft assets remain replaceable + only while they are being verified; making the draft public locks the tag and assets. The retained Actions artifact + and GitHub attestations independently bind the same files to the source SHA. -Third-party actions are pinned to a commit SHA rather than a moving tag, so a compromised action release can't -change what runs here. +Third-party actions are pinned to a commit SHA rather than a moving tag, so a compromised action release cannot change +what runs here. Repository-enforced SHA pinning is also enabled; every current action uses a full commit SHA. -**What's still true after all that.** Anyone who can approve a release can sign software as you, and anyone with -admin access can change who that is — a compromise of the GitHub account is a compromise of the certificate. The +**What's still true after all that.** Any allowed release account can cause CI to sign software as you, and anyone with +admin access can change that allowlist or the environment controls — compromising either path compromises the certificate. The App Store Connect key only notarizes and can't sign, so rotating it is cheap; the Developer ID certificates are the ones that matter, and they're revocable in Apple's developer portal. If that trade is ever the wrong one, the local recipe above still works and the workflow can sit unused. diff --git a/docs/about/index.html b/docs/about/index.html index 5ede6eb9..4413f909 100644 --- a/docs/about/index.html +++ b/docs/about/index.html @@ -48,7 +48,7 @@ - + @@ -153,7 +153,7 @@

Thanks

diff --git a/docs/assets/screenshots/agent-detail.png b/docs/assets/screenshots/agent-detail.png index c6a37dd6..82e8749a 100644 Binary files a/docs/assets/screenshots/agent-detail.png and b/docs/assets/screenshots/agent-detail.png differ diff --git a/docs/assets/screenshots/agent-tools.png b/docs/assets/screenshots/agent-tools.png index e5cb760f..e4eb2d96 100644 Binary files a/docs/assets/screenshots/agent-tools.png and b/docs/assets/screenshots/agent-tools.png differ diff --git a/docs/assets/screenshots/agents.png b/docs/assets/screenshots/agents.png index 2bf4cba4..0bfb51e3 100644 Binary files a/docs/assets/screenshots/agents.png and b/docs/assets/screenshots/agents.png differ diff --git a/docs/assets/screenshots/alerts.png b/docs/assets/screenshots/alerts.png index 916069e8..7d8ab0ea 100644 Binary files a/docs/assets/screenshots/alerts.png and b/docs/assets/screenshots/alerts.png differ diff --git a/docs/assets/screenshots/always-monitor.png b/docs/assets/screenshots/always-monitor.png index 38ad7579..dcff9ef2 100644 Binary files a/docs/assets/screenshots/always-monitor.png and b/docs/assets/screenshots/always-monitor.png differ diff --git a/docs/assets/screenshots/inspect.png b/docs/assets/screenshots/inspect.png index 8ac1f8c1..80803897 100644 Binary files a/docs/assets/screenshots/inspect.png and b/docs/assets/screenshots/inspect.png differ diff --git a/docs/assets/screenshots/live.png b/docs/assets/screenshots/live.png index 283deb0d..02e14ab6 100644 Binary files a/docs/assets/screenshots/live.png and b/docs/assets/screenshots/live.png differ diff --git a/docs/assets/screenshots/manifest.json b/docs/assets/screenshots/manifest.json index 28dcbdb6..5308ad30 100644 --- a/docs/assets/screenshots/manifest.json +++ b/docs/assets/screenshots/manifest.json @@ -3,62 +3,62 @@ "screenshots": { "agent-detail.png": { "height": 1988, - "sha256": "24d5911b28de614100c70a38db5b73c7678b68750e4635d958153e2d2f7b4345", - "version": "0.17.3", + "sha256": "3112dfc92b9c96f4260fa879219027a6abbd15e30fe6b14f06a6239c1e1b1279", + "version": "0.17.4", "width": 3200 }, "agent-tools.png": { "height": 1424, - "sha256": "ea6450cad546bc142e8eeaf4cac051f1b9d7b253bb336ee7d079278c647414c5", - "version": "0.17.3", + "sha256": "fdac9e90c0b68e4fc4d21f837b13b7b825927f8b62f67571bdd4244f915016d5", + "version": "0.17.4", "width": 3200 }, "agents.png": { "height": 1988, - "sha256": "9ab6ca6a8536b48a98a4f771bc22070e773a66dc4beea96183d452dd4976d55d", - "version": "0.17.3", + "sha256": "9283586bfa8bee4e9f89057613d1132e1e6c77d2ac030e7a010bd3a170b17d8e", + "version": "0.17.4", "width": 3200 }, "alerts.png": { "height": 1988, - "sha256": "e24902bc31cdf83513b4c908dd8a57b62ee5887712c359dc65f1c63df9263733", - "version": "0.17.3", + "sha256": "c0b5409635d8d214b06a7b5ae4d76092b6e71a6bf48e6c0ed84d35dff9f2c317", + "version": "0.17.4", "width": 3200 }, "always-monitor.png": { "height": 1988, - "sha256": "e21d673f2ab978ca767b7b6628aad3005387e8e12ff31972f1dc2c616cda207d", - "version": "0.17.3", + "sha256": "9d78773c1ef51978edb3f78bb4746c4255349daf70197b589f137ce4df5a5128", + "version": "0.17.4", "width": 3200 }, "inspect.png": { "height": 1988, - "sha256": "c74fd8d50fda010f05fbabcc4e5dfcdbf8a1207b16f1b79b3a9cc2314ef260d7", - "version": "0.17.3", + "sha256": "6dd77b55204b317e5bdef30b7ab2b8e550bb111582417ca9fc3b5fed36536518", + "version": "0.17.4", "width": 3200 }, "live.png": { "height": 1988, - "sha256": "023b487c1d3fada57c6afd800c245b01656e7790b486c67510ff848a7537eaa8", - "version": "0.17.3", + "sha256": "c1170b0027969a69107d473f0aefe227a496e54fe6536073b89a4976aa02585e", + "version": "0.17.4", "width": 3200 }, "mcp-servers.png": { "height": 1424, - "sha256": "5753c21b921d8b16904ecd819bac062238c46534dec8ffabc70e8c7cdec3d4d9", - "version": "0.17.3", + "sha256": "b3c9879845e0838c0d51b60687198191cc10d3759af2ebc6b435a9b05fe33a66", + "version": "0.17.4", "width": 3200 }, "reports.png": { "height": 1988, - "sha256": "921a83c0e6a0fa34db78223e38f6e43bfab3e476c2fc6eb748afd4eceb7b84a0", - "version": "0.17.3", + "sha256": "e7c313707a596c00b7fdc92835c89aecc0d53a0d5ed632b1b4a8b639bcc67594", + "version": "0.17.4", "width": 3200 }, "visualize.png": { "height": 1988, - "sha256": "6d140321d16f096326fd5deb3d5430f6d7327a9ac9390cf95959d29db25da132", - "version": "0.17.3", + "sha256": "76a0de61bc483aceb5cbfec1c4447ea8a09814e397369a5e7d19bc34fe42d183", + "version": "0.17.4", "width": 3200 } } diff --git a/docs/assets/screenshots/mcp-servers.png b/docs/assets/screenshots/mcp-servers.png index 6ffc9c12..05dd0a39 100644 Binary files a/docs/assets/screenshots/mcp-servers.png and b/docs/assets/screenshots/mcp-servers.png differ diff --git a/docs/assets/screenshots/reports.png b/docs/assets/screenshots/reports.png index 15998659..2c628da3 100644 Binary files a/docs/assets/screenshots/reports.png and b/docs/assets/screenshots/reports.png differ diff --git a/docs/assets/screenshots/visualize.png b/docs/assets/screenshots/visualize.png index 6d94d685..da8e96bc 100644 Binary files a/docs/assets/screenshots/visualize.png and b/docs/assets/screenshots/visualize.png differ diff --git a/docs/de/about/index.html b/docs/de/about/index.html index 529c9357..a1f76f64 100644 --- a/docs/de/about/index.html +++ b/docs/de/about/index.html @@ -48,7 +48,7 @@ - + @@ -152,7 +152,7 @@

Dank

diff --git a/docs/de/docs/index.html b/docs/de/docs/index.html index 038f0cae..616e9d11 100644 --- a/docs/de/docs/index.html +++ b/docs/de/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Dokumentation

Flowlight benutzen

-

Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.17.3, macOS 15 oder neuer.

+

Alles vom ersten Start bis zum Feinschliff an den Agentenregeln. Flowlight 0.17.7, macOS 15 oder neuer.

@@ -170,11 +170,11 @@

Die AnsichtenWarnungen⌘5Alles, was ungewöhnlich aussah, mit App, Ziel und dem Wert, der die Regel ausgelöst hat. Fragen⌘6Fragen zu deinem eigenen Datenverkehr in normaler Sprache, beantwortet aus der aufgezeichneten Historie von einem lokalen oder einem gehosteten Modell. Inspizieren⌘7 · Developer IDOptional: entschlüsselte Anfragen der Agenten, die du über Flowlight leitest, und die Tool-Aufrufe dahinter. Die Suche reicht in Anfrage- und Antwort-Bodys hinein. - Regeln⌘8 · Developer ID
⌘7 · App StoreJede Erlaubnisliste und jede Verweigerung, die du gesetzt hast, was jede davon abdeckt und was sie tatsächlich gestoppt hat. - Quellen⌘9 · Developer ID
⌘8 · App StoreCollector-Zustand, Gerätekanäle und Benennungsabdeckung in drei Tabs. + Richtlinien⌘8 · Developer ID
⌘7 · App StoreJede konfigurierte Richtlinie, ob sie tatsächlich wirksam ist, und ein einheitlicher schreibgeschützter Aktivitätsverlauf. + Quellen⌘9 · Developer ID
⌘8 · App StoreZustand und Wiederherstellung des Collectors, optionale Gerätekanäle und Abdeckung auf einer einzigen scrollbaren Betriebsseite. -

Die Kurzbefehle folgen der sichtbaren Reihenfolge der Seitenleiste. Developer-ID-Versionen enthalten Inspizieren auf ⌘7; App-Store-Versionen lassen es weg, daher rücken Regeln und Quellen auf ⌘7 und ⌘8.

-
Die Kurzbefehle folgen der sichtbaren Reihenfolge der Seitenleiste. Developer-ID-Versionen enthalten Inspizieren auf ⌘7; App-Store-Versionen lassen es weg, daher rücken Richtlinien und Quellen auf ⌘7 und ⌘8.

+
Der Bildschirm Live: eine Liste von Anwendungen mit ihren aktuellen Sende- und Empfangsraten und ihrem meistgenutzten Ziel.
Live, der Bildschirm, mit dem Flowlight startet.

Bewege in Verlauf den Zeiger über einen Balken, um eine mitlaufende, scrollbare Karte mit den beteiligten Apps zu öffnen; die Balken zoomen beim Klicken nicht. Doppelklicke eine Zeile, um den Verlauf darauf einzugrenzen.

@@ -216,7 +216,7 @@

Tools & MCP-Server

eine nur konfigurierte Domain nie erreicht wurde und eine nur beobachtete nirgends aufgeschrieben stand, wo du sie hättest lesen können.

Jede Domain bietet zwei Verweigerungen an: für diesen Agenten, was den Agenten und die von ihm gestarteten Tools abdeckt, oder überall, was jede App auf dem Mac abdeckt. Beide schreiben eine ganz gewöhnliche Regel, sodass der - Verstoß-Verlauf sie meldet und der Regeleditor sie erklärt – und weil es gewöhnliche Regeln sind, sagt dir die Simulation im Editor, was + schreibgeschützte Aktivität sie meldet und der Regeleditor sie erklärt – und weil es gewöhnliche Regeln sind, sagt dir die Simulation im Editor, was eine davon verweigert hätte, bevor du sie einschaltest.

Eine Art von Domain hat keine Knöpfe. Ein Connector, den der Modellanbieter von sich aus erreicht – im Body der Anfrage deklariert und von der Seite des Anbieters aus kontaktiert –, berührt diesen Mac überhaupt nicht, also kann keine Regel hier ihn erreichen. Die Zeile sagt @@ -407,16 +407,16 @@

Mock-Antworten

verbunden: Ein Mock ändert die Antwort, nicht die Frage, ob die Verbindung zustande kommt. „Die API liefert 500“ funktioniert also, „die API ist überhaupt nicht erreichbar“ nicht.

-

Regeln

-

Eine Erlaubnisliste ist die Vorstellung eines Agenten davon, wohin er gehen darf. Eine Regel ist die allgemeine Fassung: Sie nennt ein +

Richtlinien

+

Unter Richtlinien sind alle Kontrollen, die Flowlight anwenden kann, in einem gemeinsamen Verzeichnis zusammengefasst: gewöhnliche Erlaubnis- und Sperrregeln, Agenten-Erlaubnislisten, verwaltete Pakete und – wo die HTTPS-Inspektion verfügbar ist – Schutzregeln, Mock-Antworten, Anfrageumschreibungen und Antworttransformationen. Jede Zeile unterscheidet den konfigurierten Zustand von der tatsächlichen Wirkung: wirksam, nur meldend, beobachtend, wartend, geplant, pausiert, verbraucht, in Anwendung, Prüfung erforderlich, nicht verfügbar, inkompatibel oder fehlgeschlagen. Wenn du eine Zeile auswählst, öffnet sich der zugehörige Editor dieser Richtlinie; die gemeinsame Ansicht ersetzt oder vermischt nicht deren unterschiedliche Einwilligungs- und Speichermodelle.

+

Eine Erlaubnisliste ist die Vorstellung eines Agenten davon, wohin er gehen darf. Eine gewöhnliche Regel ist die allgemeine Fassung: Sie nennt ein Subjekt – eine App, einen Agenten, ein Ziel, eine URL oder ein Paar aus zwei davon – und sagt blockieren oder erlauben. Rechtsklicke eine Zeile in Live, Verlauf, KI-Agenten oder Inspizieren, um eine Regel über das zu schreiben, was du gerade ansiehst; - jede davon landet im selben Editor auf dem Bildschirm Regeln, denn eine Regel, die im Verborgenen greift, - wäre eine Regel, die du nie wiederfindest.

+ jede bleibt unter Richtlinien auffindbar und öffnet sich in ihrem maßgeblichen Editor.

Vorlagen. Zweiundzwanzig, in fünf Gruppen – einen Agenten von Paketregistern oder anderen Modellanbietern fernhalten, die üblichen Wege schließen, auf denen Daten abfließen (Paste-Dienste, Webhook-Fänger, anonyme Datei-Ablagen, Tunnel, DNS over HTTPS), Produktanalyse und Crash-Berichte, Regeln für Arbeitszeiten und das einmalige „blockiere das eine Stunde lang, bis ich - herausgefunden habe, was es ist“. Durchsuche sie über die Symbolleiste von Regeln, oder rechtsklicke irgendwo in Live, Verlauf, KI-Agenten oder Inspizieren eine Zeile und + herausgefunden habe, was es ist“. Durchsuche sie über die Symbolleiste von Richtlinien, oder rechtsklicke irgendwo in Live, Verlauf, KI-Agenten oder Inspizieren eine Zeile und wende eine auf das an, was du ansiehst.

Jede Vorlage sagt, was sie tun wird, bevor sie es tut. Alles, was mehr als eine Regel schreibt, öffnet eine Karte mit jedem Host und jedem Zeitplan; alles Dauerhafte öffnet den vorausgefüllten Editor; und nur Regeln, die sich selbst aufheben – nach einer @@ -437,8 +437,7 @@

Regeln

Adresse und Port, nichts über Pfade, und das nur im Erweiterungsmodus. Die HTTPS-Inspektion verweigert eine einzelne Anfrage, kann also eine URL treffen und mit einem Status antworten, den der Agent lesen kann, statt mit einem Socket, der ohne Erklärung starb. Der nettop-Sampler kann überhaupt nichts verweigern. Eine Regel, die die aktuelle Einrichtung nicht umsetzen kann, sagt das in der Liste, statt dort durchgesetzt auszusehen.

-

Der zweite Tab ist der Verstoß-Verlauf: jede Verbindung, über die eine Regel entschieden hat, mit der App, dem Ziel, welche - Regel entschied und ob das eine Verweigerung war oder eine Ausnahme, die sie durchließ.

+

Die zweite Ansicht ist die schreibgeschützte Aktivität: gewöhnliche Regelentscheidungen, Verstöße gegen Erlaubnislisten, Ereignisse verwalteter Pakete und eigenständige Wirkungen von Inspektionsrichtlinien in einer gemeinsamen Chronologie. Sie bereitet die bestehenden maßgeblichen Einträge auf, statt den Verlauf in ein einheitliches allgemeines Ereignismodell umzuschreiben; App-Store-Versionen lassen von der Inspektion abhängige Richtlinienarten und deren aufbewahrte Aktivität weg.

Was sie bewirkt hätte. Eine Regel, die blockiert, ist leicht zu schreiben und beängstigend einzuschalten, denn das Erste, was man normalerweise über eine solche Regel erfährt, ist, was sie kaputt gemacht hat – und man erfährt es später, @@ -460,7 +459,7 @@

Regeln

getestet wurde, ist gegen zwei Stunden Aufzeichnung getestet.

Verwaltete Werbe- und Tracker-Pakete

-

Regeln › Pakete enthält drei offizielle Hostnamenlisten: Werbung, Websiteübergreifendes Tracking und Datenschutz und Telemetrie. Sie sind von Anfang an sichtbar und standardmäßig alle deaktiviert. Zum Blockieren ist die Netzwerkerweiterung erforderlich; der nettop-Sampler kann nur beobachten.

+

Richtlinien › Verwaltete Pakete enthält drei offizielle Hostnamenlisten: Werbung, Websiteübergreifendes Tracking und Datenschutz und Telemetrie. Sie sind von Anfang an sichtbar und standardmäßig alle deaktiviert. Zum Blockieren ist die Netzwerkerweiterung erforderlich; der nettop-Sampler kann nur beobachten.

Ausschließlich offizielle, signierte Updates. Flowlight prüft eine einzige festgelegte HTTPS-Quelle auf neuere, vom Herausgeber signierte Kataloge. Automatische Prüfungen sind standardmäßig aktiviert und können hier deaktiviert werden. Eine Signatur authentifiziert den Inhalt, aktiviert ihn aber nicht: Ein neues Paket bleibt deaktiviert, und bei geänderten Zielen wird ein aktiviertes Paket nicht mehr durchgesetzt, bis du die genaue neue Version geprüft und auf Sichern geklickt hast. Akzeptierte Inhalte können während eines Ausfalls als sichtbar veralteter letzter bekannter funktionsfähiger Stand erhalten bleiben. Dies sind Netzwerksperren auf Hostnamenebene: Sie können keine Lücken im DOM oder Layout, Browser-Overlays, skriptgesteuerten Pop-ups oder Fenster nativer Apps entfernen.

Wenn die HTTPS-Inspektion eine App durch Flowlights Proxy leitet, entscheidet er anhand der ursprünglichen App-Identität über gewöhnliche Zielregeln und aktivierte verwaltete Pakete, bevor eine Verbindung zum Ursprungsserver geöffnet wird. Bei angepinnten oder nie entschlüsselten CONNECT-Tunneln funktioniert das anhand des Hostnamens; Agenten-Erlaubnislisten bleiben auf den direkten Weg über die Netzwerkerweiterung beschränkt. Ist der Eigentümer oder Hostname unbekannt, wird die Verbindung im Zweifel zugelassen.

@@ -509,7 +508,8 @@

Schutzregeln für Agenten: ein Tool, einen Server oder eine

Das ist keine Sandbox. Ein verweigertes Tool wird nicht mehr angeboten und nicht mehr beantwortet. Ein Agent, der noch eine Shell hat, kann von Hand tun, was das Tool getan hätte. Flowlight zeichnet das auf; es tut nicht so, als würde es das verhindern.

-

Quellen: Collector und Engines

+

Quellen: Collector, Geräte und Abdeckung

+

Quellen ist eine einzige scrollbare Betriebsseite: zuerst Collector, dann Geräte und Abdeckung als klar bezeichnete Abschnitte. Statushinweise, Antworten unter Fragen, wiederhergestellte Fenster und Links aus Screenshots lassen die Seite zum passenden Abschnitt scrollen und heben ihn kurz hervor, ohne die anderen auszublenden.

Flowlight zeichnet Datenverkehr auf eine von zwei Weisen auf, wählbar unter Quellen › Collector. Sie unterscheiden sich darin, was sie verpassen können, und das lohnt sich zu verstehen, bevor du dich auf eine verlässt.

@@ -706,7 +706,7 @@

Fragen & Antworten

Mit geschlossenem Fenster tut Flowlight fast nichts: Es hält die Raten in der Menüleiste und schreibt eine Zeile pro Sekunde. Die Netzwerkerweiterung kostet weniger als der Sampler als Rückfallebene, der jede Sekunde einen kurzlebigen nettop-Prozess startet.
Kann es Verbindungen blockieren?
-
Ja, mit der Netzwerkerweiterung. Die Erlaubnisliste eines Agenten kann verweigern und nicht nur warnen, eine Regel kann eine App, +
Ja, mit der Netzwerkerweiterung. Die Erlaubnisliste eines Agenten kann verweigern und nicht nur warnen, eine Regel kann eine App, ein Ziel oder eine URL so lange blockieren, wie du sagst, und eine Schutzregel kann einem Agenten ein Tool wegnehmen, bevor sein Modell es angeboten bekommt. Nichts blockiert, bis du es verlangst, und jede Verweigerung führt ihren Ausweg mit.
Brauche ich die Netzwerkerweiterung?
@@ -761,7 +761,7 @@

Grenzen

diff --git a/docs/de/index.html b/docs/de/index.html index 00e84079..47136ce9 100644 --- a/docs/de/index.html +++ b/docs/de/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

Sieh, was deine Apps im Netz tun.
brew install --cask xinbetween/tap/flowlight

-

v0.17.3macOS 15+UniversalGPL-3.0Keine Telemetrie

+

v0.17.7macOS 15+UniversalGPL-3.0Keine Telemetrie

@@ -127,7 +127,7 @@

Sieh, was deine Apps im Netz tun.
-
Flowlights Karte der KI-Aktivität. Agentenkarten führen über zugeordnete Werkzeuge und konfigurierte Gateways; die beobachtete Route von magpie über Flowlight verzweigt sich anschließend zu LLM-Diensten und Domains. Die Bandbreite zeigt das Verkehrsvolumen des Zeitraums.

Sieh die tatsächliche KI-Route: Agenten, zugeordnete Werkzeuge, konfigurierte Gateways, Flowlights beobachtete Proxy-Stufe und exakte Ziele. Die Bandbreite zeigt empfangene plus gesendete Daten im gewählten Zeitraum. Mit Demodaten.

@@ -135,7 +135,7 @@

Sieh, was deine Apps im Netz tun.
-
Flowlights Ansicht KI-Agenten. Claude Code, Cursor, Codex und ein Python-Skript mit ihren KI-Anbietern, dem woanders hin gesendeten Datenverkehr und Risikomarkierungen für SMTP, SSH, FTP, einen großen Upload und eine reine IP.

Die Ansicht KI-Agenten: der Modellanbieter jedes Agenten – und alles andere, mit dem er gesprochen hat. Mit Demodaten gezeigt.

@@ -179,11 +179,11 @@

Verlauf

Wo ist alles hingegangen? Sekunde bis Jahr, gruppiert nach Ap

Warnungen

Was sah falsch aus? Nachvollziehbare Warnungen pro App, mit Ziel und Schwellenwert.

⌘5
-

Regeln

Was habe ich verweigert – und passiert es? Jede Regel, was sie blockiert hat, und ein Verlauf jeder Entscheidung.

⌘8 · Developer ID / ⌘7 · App Store
+

Richtlinien

Welche Kontrollen gibt es, und sind sie wirksam? Jede Richtlinie, ihr tatsächlicher Zustand und ein einheitlicher Aktivitätsverlauf.

⌘8 · Developer ID / ⌘7 · App Store

Fragen

Fragen in normaler Sprache, beantwortet aus dem Verlauf – mit jeder ausgeführten Abfrage darunter.

⌘6
-

Quellen

Kommen Daten an, wie viel ist benannt und welche Gerätekanäle sind aktiv? Collector, Geräte und Abdeckung an einem Ort.

⌘9 · Developer ID / ⌘8 · App Store
+

Quellen

Kommen Daten an, welche Gerätekanäle sind aktiv, und was kann Flowlight erfassen? Eine Seite vom Collector bis zur Abdeckung.

⌘9 · Developer ID / ⌘8 · App Store

Inspizieren

Optional. Was hat der Agent wirklich gesendet, und welcher Tool-Aufruf hat es ausgelöst? Entschlüsselte Anfragen, Tool-Aufrufe, Ergebnisse.

⌘7 · Developer ID
@@ -405,16 +405,16 @@

Unterstützte Tool-Aufrufe mit Netzwerkanfragen verknüpfen

Mac. Starte einen Agenten aus einem inspizierten Terminal, und der unterstützte HTTP-Verkehr des Agenten und seiner Tools wird entschlüsselt – mit den Tool-Aufrufen, die das Modell angefordert hat, und den Ergebnissen, die zurückgingen.

-
Flowlights Ansicht Inspizieren: eine Liste entschlüsselter Anfragen von Claude Code mit ihren Tool-Aufrufen und das Detail des curl-Uploads zu paste.example, ausgelöst vom Bash-Tool-Aufruf, mit den hochgeladenen Notizen im Anfrage-Body
Wo sind meine Notizen gelandet? Der curl-Upload zu paste.example, zurückverfolgt bis zum Bash-Befehl, den das Modell angefordert hat, mit dem gesendeten Body.
-
Der Tab Tools für Claude Code: Bash, Read und zwei MCP-Tools mit Beschreibungen und der Häufigkeit, mit der das Modell sie aufgerufen hat
Jedes Tool, das ein Agent dem Modell anbietet. Agenten deklarieren ihre Tools bei jedem Zug, also listet Flowlight alle auf – mit denen, die das Modell tatsächlich genutzt hat, und denen, die es nie angefasst hat.
-
Der Tab MCP-Server für Codex: ein sentry-Server, markiert als über den Anbieter, seine URL, Freigabe auf nie gesetzt, erlaubte Tools und die Tools, die er anbietet
Auch die, die du auf der Leitung nicht siehst. Bittet ein Agent den Anbieter, sich mit einem MCP-Server zu verbinden, erreicht dieser Datenverkehr deinen Mac nie. Seine Anfrage schon – also tauchen der Server, seine URL, seine Freigabeeinstellung und seine Tools trotzdem auf.
@@ -460,7 +460,7 @@

Ein Wächter, den du nicht bewachen musst

-
Das Detail von Claude Code in Flowlight: Tools und MCP-Server mit Aufrufzahlen und einem Fehlschlag, eine Erlaubnisliste und der Tab Tool-Aufrufe mit einem fehlgeschlagenen git push, einem curl-Upload zu paste.example samt Antwort und Anfrage, npm install, einem GitHub-Issue, einer Doku-Suche und einem Datei-Lesevorgang, jeweils mit Ergebnis
Ein Agent, jeder Tool-Aufruf. Der Datenverkehr der git-, curl- und MCP-Server, die Claude Code gestartet hat, zählt als Datenverkehr von Claude Code. Mit eingeschalteter HTTPS-Inspektion zeigt der Tab Tool-Aufrufe, was das Modell angefordert hat, was zurückkam und welche Anfragen jedes Tool gestellt hat: @@ -470,10 +470,10 @@

Ein Wächter, den du nicht bewachen musst

-
Die Ansicht Verlauf mit stündlichen Balken für empfangene und gesendete Daten und Ringdiagrammen für Apps, Ziele und Protokolle
Verlauf. Zoom von der Sekunde bis zum Jahr, drei Gruppierungen, Diagramme, die auch bei hunderten Apps lesbar bleiben.
-
Die Ansicht Warnungen mit Warnungen zu Agenten-Exfiltration, E-Mail, FTP, Aktivität bei Abwesenheit und einer Verkehrsspitze
Warnungen. Jede nennt die App, das Ziel und den Wert, der sie ausgelöst hat.
@@ -589,7 +589,7 @@

Wisse, was deinen Mac verlässt.

diff --git a/docs/de/privacy/index.html b/docs/de/privacy/index.html index 4879b58a..e98eb8d7 100644 --- a/docs/de/privacy/index.html +++ b/docs/de/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -125,7 +125,7 @@

Netzwerkanfragen, die die App stellt

- + @@ -189,7 +189,7 @@

Kontakt

diff --git a/docs/de/threat-model/index.html b/docs/de/threat-model/index.html index c00bcb1b..05136d5f 100644 --- a/docs/de/threat-model/index.html +++ b/docs/de/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -225,7 +225,7 @@

Eine Schwachstelle melden

diff --git a/docs/docs/index.html b/docs/docs/index.html index c070431a..50c4f2c8 100644 --- a/docs/docs/index.html +++ b/docs/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Documentation

Using Flowlight

-

Everything from the first launch to tuning the agent rules. Flowlight 0.17.3, macOS 15 or later.

+

Everything from the first launch to tuning the agent rules. Flowlight 0.17.7, macOS 15 or later.

@@ -170,11 +170,11 @@

The views

- - + +
AnfrageGesendet anWas sie enthältWann
Update-Prüfungapi.github.comEine normale HTTPS-Anfrage nach der neuesten Version; GitHub sieht deine IP-Adresse und den User-Agent Flowlight/<version>Beim Start und täglich, ab 0.1.2. Abschalten in Einstellungen › Allgemein
Update-Downloadgithub.comDas Image und seine PrüfsummendateiNur wenn du auf „Herunterladen & öffnen“ klickst
Offizielle Regelpaket-Aktualisierungflowlight.xinbetween.comEine standardmäßige HTTPS-Anfrage an den festgelegten offiziellen Feed. Der Server sieht deine IP-Adresse, den User-Agent Flowlight/<version> und bedingte Cache-Validatoren; er erhält weder Datenverkehr noch Paketeinstellungen oder eine GerätekennungNach dem Start, sobald eine Prüfung fällig ist, und danach bei störungsfreiem Betrieb höchstens einmal täglich. Standardmäßig aktiviert; Ausschalter und manuelle Option Jetzt suchen unter Regeln › Pakete
Offizielle Regelpaket-Aktualisierungflowlight.xinbetween.comEine standardmäßige HTTPS-Anfrage an den festgelegten offiziellen Feed. Der Server sieht deine IP-Adresse, den User-Agent Flowlight/<version> und bedingte Cache-Validatoren; er erhält weder Datenverkehr noch Paketeinstellungen oder eine GerätekennungNach dem Start, sobald eine Prüfung fällig ist, und danach bei störungsfreiem Betrieb höchstens einmal täglich. Standardmäßig aktiviert; Ausschalter und manuelle Option Jetzt suchen unter Richtlinien › Verwaltete Pakete
Reverse-DNSDer von dir eingerichtete DNS-ResolverEine Abfrage des registrierten Namens einer IP-Adresse, wenn kein Hostname gesehen wurdeNach Bedarf, im Erfassungsmodus des Samplers, der Standard ist, bis die Netzwerkerweiterung läuft. Kein Schalter zum Abschalten: ohne sie kann der Sampler die meisten Verbindungen nicht benennen. Die Netzwerkerweiterung benennt sie aus dem Datenverkehr selbst und macht diese Abfrage nicht
Abfrage des NetzinhabersTeam Cymru, über DNSÖffentliche IP-Adressen ohne bekannten Hostnamen. Private und reservierte Adressen verlassen deinen Mac nieStandardmäßig ein. Abschalten in Erfassung › Netzinhaber bestimmen
Fragen mit einem gehosteten ModellDer Anbieter, den du wählst: Anthropic, OpenAI, Google Gemini oder ein kompatibler EndpunktDeine Frage und die Ergebnisse der nur lesenden Flowlight-Abfragen, die das Modell ausgewählt hat. Die Verkehrsdatenbank selbst wird nicht hochgeladenNur wenn du einen gehosteten Anbieter wählst und eine Frage abschickst. Der Bildschirm Fragen zeigt die gesendeten Anfragen
Alerts⌘5Everything that looked unusual, with the app, the destination and the number that tripped the rule.
Ask⌘6Questions about your own traffic in plain language, answered from the recorded history by a local or a hosted model.
Inspect⌘7 · Developer IDOptional: decrypted requests from the agents you route through Flowlight, and the tool calls behind them. Search reaches into request and response bodies.
Rules⌘8 · Developer ID
⌘7 · App Store
Every allowlist and refusal you have set, what each one covers, and what it has actually stopped.
Sources⌘9 · Developer ID
⌘8 · App Store
Collector health and recovery, hostname coverage, and opt-in camera, Bluetooth, USB and storage activity in three tabs.
Policies⌘8 · Developer ID
⌘7 · App Store
Every configured policy, whether it is actually effective, and a unified read-only activity trail.
Sources⌘9 · Developer ID
⌘8 · App Store
Collector health and recovery, opt-in device channels, and coverage as one scrollable operational page.
-

The shortcuts follow the visible sidebar order. Developer ID builds include Inspect at ⌘7; App Store builds omit Inspect, so Rules and Sources move to ⌘7 and ⌘8.

-
The shortcuts follow the visible sidebar order. Developer ID builds include Inspect at ⌘7; App Store builds omit Inspect, so Policies and Sources move to ⌘7 and ⌘8.

+
The Live screen: a list of applications with their current send and receive rates and busiest destination.
Live, the screen Flowlight opens on.

In History, choose 15 min, 1h, 6h, 1d, 1w, 1 mo, 6 mo, 1y, or All. Hover-only bars open a cursor-following, scrollable card with the bucket totals and contributing apps; they never change the selected range. The navigator below the chart starts with Nearby context and can switch to All history: drag the brush to pan the selected window, or drag either edge to resize it. Double-click any breakdown row to narrow History to it.

@@ -216,7 +216,7 @@

Tools & MCP servers

in a configuration file and actually contacted says more than either alone, while a domain only ever configured has never been reached, and one only ever observed was never written down anywhere you could have read it.

Each domain offers two refusals: for this agent, which covers the agent and the tools it starts, or - everywhere, which covers every app on the Mac. Both write an ordinary rule, so the violations feed reports + everywhere, which covers every app on the Mac. Both write an ordinary rule, so Activity reports them and the rule editor explains them — and because they are ordinary rules, the simulation in the editor will tell you what one would have refused before you switch it on.

One kind of domain has no buttons. A connector the model provider reaches on its own — declared in the request body and @@ -415,16 +415,20 @@

Mock responses

connected to: a mock changes the answer, not whether the connection can be made, so "the API returns 500" works while "the API is entirely unreachable" does not.

-

Rules

-

An allowlist is one agent's idea of where it may go. A rule is the general version: it names a +

Policies

+

Policies is one inventory for every control Flowlight can apply: ordinary allow/block rules, agent + allowlists, managed packs, and—where HTTPS inspection is available—guardrails, mocks, request rewrites, and response + transforms. Each row distinguishes configured state from actual effect: enforcing, reporting, watching, waiting, + scheduled, paused, spent, applying, review required, unavailable, incompatible, or failed. Selecting a row opens the + contextual editor that owns that policy; Policies does not replace or blur those separate consent and persistence models.

+

An allowlist is one agent's idea of where it may go. An ordinary rule is the general version: it names a subject — an app, an agent, a destination, a URL, or a pairing of two of those — and says block or allow. Right-click any row in Live, History, AI Agents or Inspect to write one about what you're looking at; - every one of them lands in the same editor on the Rules screen, because a rule that took effect out of sight - would be a rule you could never find again.

+ every one remains findable in Policies and opens in its authoritative editor.

Templates. Twenty-two of them, in five groups — stopping an agent reaching package registries or other model providers, closing the common ways data leaves (paste sites, webhook catchers, anonymous file drops, tunnels, DNS over HTTPS), product analytics and crash reporting, working-hours policies, and one-off "block this for an hour while I work out - what it is". Browse them from the Rules toolbar, or right-click a row anywhere in Live, History, AI Agents or Inspect and + what it is". Browse them from the Policies toolbar, or right-click a row anywhere in Live, History, AI Agents or Inspect and apply one to what you're looking at.

Each template says what it will do before it does it. Anything that writes more than one rule opens a card listing every host and schedule; anything permanent opens the editor pre-filled; and only rules that undo themselves — an @@ -445,8 +449,9 @@

Rules

address and the port, nothing about paths, and only in extension mode. HTTPS inspection refuses one request, so it can match a URL and answer with a status the agent can read instead of a socket that died without saying why. The nettop sampler cannot refuse anything at all. A rule the current setup can't carry out says so in the list rather than sitting there looking enforced.

-

The second tab is the violations feed: every connection a rule decided, with the app, where it was headed, which - rule decided it and whether that was a refusal or an exception letting it through.

+

The second view is read-only Activity: ordinary rule decisions, allowlist violations, managed-pack events, + and distinct inspection-policy effects in one chronology. It adapts the existing authoritative records rather than rewriting + history into one generic event model; Store builds omit inspection-dependent policy kinds and their retained activity.

What it would have done. A blocking rule is easy to write and frightening to switch on, because the first thing you normally learn about one is what it broke — and you learn it later, from something failing somewhere else. The rule @@ -466,7 +471,7 @@

Rules

history has been tested against two hours of history.

Managed ad and tracker packs

-

Rules › Packs contains three official lists built into Flowlight: Advertising covers known +

Policies › Managed packs contains three official lists built into Flowlight: Advertising covers known advertising delivery and auction hosts; Cross-site tracking covers common analytics, session replay and cross-site measurement hosts; and Privacy and telemetry covers crash reporting, install attribution, and audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default.

@@ -551,8 +556,10 @@

Agent guardrails: block a tool, a server or a resource

This is not a sandbox. A refused tool stops being offered and stops being answered. An agent that still has a shell can do by hand what the tool would have done. Flowlight will record that; it does not pretend to prevent it.

-

Sources: collector, coverage and devices

-

Sources keeps operational setup in one place. Its health summary shows whether traffic is arriving, how much is named, and which optional device channels are active; any unfinished collector step links directly to Sources › Collector.

+

Sources: collector, devices and coverage

+

Sources is one scrollable operational page: Collector first, then Devices and Coverage as clearly headed + sections. Status notices, Ask answers, restored windows, and screenshot links scroll to and briefly focus the relevant section + without hiding the others.

Flowlight records traffic one of two ways, chosen under Sources › Collector. They differ in what they can miss, which is worth understanding before you rely on either.

@@ -755,7 +762,7 @@

Questions & answers

With the window closed Flowlight does almost nothing: it keeps the menu bar rates and writes one row a second. The Network Extension costs less than the fallback sampler, which starts a short-lived nettop process every second.
Can it block connections?
-
Yes, with the Network Extension. An agent's allowlist can refuse as well as warn, a rule can block an app, +
Yes, with the Network Extension. An agent's allowlist can refuse as well as warn, a rule can block an app, a destination or a URL for as long as you say, and a guardrail can take a tool away from an agent before its model is offered it. Nothing blocks until you ask it to, and every refusal carries its way out.
Do I need the Network Extension?
@@ -811,7 +818,7 @@

Limitations

diff --git a/docs/es/about/index.html b/docs/es/about/index.html index 3b7c8cdd..04a51b91 100644 --- a/docs/es/about/index.html +++ b/docs/es/about/index.html @@ -48,7 +48,7 @@ - + @@ -152,7 +152,7 @@

Agradecimientos

diff --git a/docs/es/docs/index.html b/docs/es/docs/index.html index 02340914..3b093437 100644 --- a/docs/es/docs/index.html +++ b/docs/es/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Documentación

Usar Flowlight

-

Todo, desde el primer arranque hasta el ajuste de las reglas de agentes. Flowlight 0.17.3, macOS 15 o posterior.

+

Todo, desde el primer arranque hasta el ajuste de las reglas de agentes. Flowlight 0.17.7, macOS 15 o posterior.

@@ -170,11 +170,11 @@

Las vistas

- - + +
Alertas⌘5Todo lo que ha resultado inusual, con la app, el destino y la cifra que activó la regla.
Preguntar⌘6Preguntas sobre tu propio tráfico en lenguaje corriente, respondidas a partir del historial registrado por un modelo local o alojado.
Inspeccionar⌘7 · Developer IDOpcional: las peticiones descifradas de los agentes que encamines por Flowlight, y las llamadas de herramientas que hay detrás. La búsqueda llega a los cuerpos de las peticiones y las respuestas.
Reglas⌘8 · Developer ID
⌘7 · App Store
Cada lista de permitidos y cada rechazo que hayas puesto, qué abarca cada uno y qué ha detenido realmente.
Fuentes⌘9 · Developer ID
⌘8 · App Store
Estado del colector, canales de dispositivos y cobertura de nombres en tres pestañas.
Políticas⌘8 · Developer ID
⌘7 · App Store
Cada política configurada, si está surtiendo efecto de verdad y un historial de actividad unificado de solo lectura.
Fuentes⌘9 · Developer ID
⌘8 · App Store
Estado y recuperación del colector, canales de dispositivos opcionales y cobertura en una sola página operativa desplazable.
-

Los atajos siguen el orden visible de la barra lateral. Las versiones Developer ID incluyen Inspeccionar en ⌘7; las de App Store lo omiten, así que Reglas y Fuentes pasan a ⌘7 y ⌘8.

-
Los atajos siguen el orden visible de la barra lateral. Las versiones Developer ID incluyen Inspeccionar en ⌘7; las de App Store lo omiten, así que Políticas y Fuentes pasan a ⌘7 y ⌘8.

+
La pantalla En vivo: una lista de aplicaciones con sus tasas actuales de envío y recepción y su destino más activo.
En vivo, la pantalla con la que se abre Flowlight.

En Historial, pasa el cursor sobre una barra para abrir una tarjeta desplazable que sigue al puntero y muestra las apps que contribuyeron; las barras no amplían al hacer clic. Haz doble clic en cualquier fila para limitar el historial a ella.

@@ -215,7 +215,7 @@

Herramientas y servidores MCP

separado, mientras que un dominio solo configurado nunca se ha alcanzado, y uno solo observado no estaba escrito en ningún sitio donde pudieras haberlo leído.

Cada dominio ofrece dos rechazos: para este agente, que cubre al agente y a las herramientas que arranca, o en - todas partes, que cubre todas las apps del Mac. Ambos escriben una regla corriente, así que el flujo de incumplimientos los informa + todas partes, que cubre todas las apps del Mac. Ambos escriben una regla corriente, así que Actividad los registra y el editor de reglas los explica — y, por ser reglas corrientes, la simulación del editor te dirá qué habría rechazado uno de ellos antes de que lo actives.

Hay un tipo de dominio que no trae botones. Un conector al que el proveedor del modelo llega por su cuenta —declarado en el cuerpo de la @@ -404,16 +404,16 @@

Respuestas simuladas

una simulación cambia la respuesta, no si la conexión puede hacerse, así que «la API devuelve 500» funciona y «la API es del todo inalcanzable» no.

-

Reglas

-

Una lista de permitidos es la idea que tiene un agente de adónde puede ir. Una regla es la versión general: nombra un +

Políticas

+

Políticas reúne en un solo inventario todos los controles que Flowlight puede aplicar: reglas normales de permiso o bloqueo, listas de permitidos de agentes, paquetes gestionados y, donde esté disponible la inspección HTTPS, salvaguardas, simulaciones, reescrituras de peticiones y transformaciones de respuestas. Cada fila distingue entre el estado configurado y el efecto real: en vigor, solo informando, vigilando, en espera, programada, en pausa, agotada, en proceso de aplicación, pendiente de revisión, no disponible, incompatible o fallida. Al seleccionar una fila se abre el editor contextual al que pertenece esa política; Políticas no sustituye ni confunde sus distintos modelos de consentimiento y persistencia.

+

Una lista de permitidos es la idea que tiene un agente de adónde puede ir. Una regla normal es la versión general: nombra un sujeto —una app, un agente, un destino, una URL o una pareja de dos de ellos— y dice bloquear o permitir. Haz clic derecho en cualquier fila de En vivo, Historial, Agentes de IA o Inspeccionar para escribir una sobre lo que estás mirando; - todas acaban en el mismo editor de la pantalla Reglas, porque una regla que surtiera efecto fuera de la vista - sería una regla que ya nunca podrías encontrar.

+ todas siguen localizables en Políticas y se abren en su editor de referencia.

Plantillas. Veintidós, en cinco grupos: impedir que un agente llegue a registros de paquetes o a otros proveedores de modelos, cerrar las vías habituales por las que salen los datos (sitios de pegado, receptores de webhooks, servicios anónimos de subida de archivos, túneles, DNS over HTTPS), analítica de producto e informes de errores, políticas de horario laboral, y el «bloquea esto una hora mientras averiguo - qué es» puntual. Explóralas desde la barra de herramientas de Reglas, o haz clic derecho en una fila de En vivo, Historial, Agentes de IA o Inspeccionar y + qué es» puntual. Explóralas desde la barra de herramientas de Políticas, o haz clic derecho en una fila de En vivo, Historial, Agentes de IA o Inspeccionar y aplica una a lo que estés mirando.

Cada plantilla dice qué va a hacer antes de hacerlo. Todo lo que escriba más de una regla abre una tarjeta con la lista de cada host y cada horario; todo lo permanente abre el editor ya rellenado; y solo las reglas que se deshacen solas —una @@ -434,8 +434,7 @@

Reglas

dirección y el puerto, nada de rutas, y solo en el modo de extensión. La inspección HTTPS rechaza una petición concreta, así que puede casar con una URL y responder con un estado que el agente pueda leer en lugar de un socket que murió sin explicar por qué. El muestreador nettop no puede rechazar nada en absoluto. Una regla que la configuración actual no puede aplicar lo dice en la lista en vez de quedarse ahí aparentando estar en vigor.

-

La segunda pestaña es el flujo de incumplimientos: cada conexión que decidió una regla, con la app, adónde iba, qué - regla lo decidió y si fue un rechazo o una excepción que la dejó pasar.

+

La segunda vista es Actividad, de solo lectura: reúne en una cronología las decisiones de reglas normales, los incumplimientos de listas de permitidos, los eventos de paquetes gestionados y los efectos diferenciados de las políticas de inspección. Adapta los registros de referencia existentes en vez de reescribir el historial en un modelo de eventos genérico; las versiones de la App Store omiten los tipos de políticas que dependen de la inspección y su actividad conservada.

Qué habría hecho. Una regla de bloqueo es fácil de escribir y da miedo activarla, porque lo primero que normalmente averiguas sobre una regla así es qué rompió —y lo averiguas más tarde, porque algo falla en otro sitio—. El editor de reglas @@ -454,7 +453,7 @@

Reglas

probado con dos horas de historial.

Paquetes gestionados de publicidad y rastreadores

-

Reglas › Paquetes contiene tres listas oficiales de nombres de host: Publicidad, Seguimiento entre sitios y Privacidad y telemetría. Son visibles desde el principio y todas están desactivadas de forma predeterminada. El bloqueo requiere la Extensión de red; el muestreador nettop solo puede observar.

+

Políticas › Paquetes gestionados contiene tres listas oficiales de nombres de host: Publicidad, Seguimiento entre sitios y Privacidad y telemetría. Son visibles desde el principio y todas están desactivadas de forma predeterminada. El bloqueo requiere la Extensión de red; el muestreador nettop solo puede observar.

Solo actualizaciones oficiales y firmadas. Flowlight consulta una única fuente HTTPS fija para buscar catálogos más recientes firmados por el editor. Las comprobaciones automáticas están activadas de forma predeterminada y pueden desactivarse aquí. Una firma autentica el contenido, pero no lo activa: un paquete nuevo permanece desactivado y, si cambian los destinos, un paquete activado deja de aplicarse hasta que revises la nueva versión exacta y pulses Guardar. Durante una interrupción, el contenido aceptado puede conservarse como la última versión válida conocida, marcada visiblemente como obsoleta. Estos son bloqueos de red a nivel de nombre de host: no pueden eliminar huecos del DOM o del diseño, superposiciones del navegador, ventanas emergentes generadas por scripts ni ventanas de aplicaciones nativas.

Cuando la inspección HTTPS encamina una app por el proxy de Flowlight, este decide las reglas de destino normales y los paquetes gestionados activados usando la identidad original de la app antes de abrir ninguna conexión con el servidor de origen. En los túneles CONNECT con certificado fijado o que nunca se descifran, esto funciona por nombre de host; las listas de permitidos de agentes siguen limitadas al camino directo por la Extensión de red. Si no se conoce el propietario o el nombre de host, la conexión se permite.

@@ -503,7 +502,8 @@

Salvaguardas de agente: bloquear una herramienta, un servido

Esto no es un sandbox. Una herramienta rechazada deja de ofrecerse y deja de responderse. Un agente que siga teniendo un shell puede hacer a mano lo que habría hecho la herramienta. Flowlight lo registrará; no pretende impedirlo.

-

Fuentes: colector y motores

+

Fuentes: colector, dispositivos y cobertura

+

Fuentes es una sola página operativa desplazable: primero Colector y después Dispositivos y Cobertura como secciones claramente encabezadas. Los avisos de estado, las respuestas de Preguntar, las ventanas restauradas y los enlaces de capturas de pantalla hacen que la página se desplace hasta la sección correspondiente y la resaltan brevemente sin ocultar las demás.

Flowlight registra el tráfico de una de dos maneras, que se eligen en Fuentes › Colector. Se diferencian en lo que pueden perderse, y conviene entenderlo antes de confiar en cualquiera de las dos.

@@ -699,7 +699,7 @@

Preguntas y respuestas

Con la ventana cerrada, Flowlight casi no hace nada: mantiene las tasas de la barra de menús y escribe una fila por segundo. La extensión de red cuesta menos que el muestreador de reserva, que arranca un proceso nettop efímero cada segundo.
¿Puede bloquear conexiones?
-
Sí, con la extensión de red. La lista de permitidos de un agente puede rechazar además de avisar, una regla puede bloquear una app, +
Sí, con la extensión de red. La lista de permitidos de un agente puede rechazar además de avisar, una regla puede bloquear una app, un destino o una URL el tiempo que digas, y una salvaguarda puede quitarle una herramienta a un agente antes de que se le ofrezca a su modelo. Nada se bloquea hasta que tú lo pides, y cada rechazo lleva consigo su salida.
¿Necesito la extensión de red?
@@ -754,7 +754,7 @@

Limitaciones

diff --git a/docs/es/index.html b/docs/es/index.html index 1b4e7b85..48082107 100644 --- a/docs/es/index.html +++ b/docs/es/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

Observa la actividad de red de tus apps.

brew install --cask xinbetween/tap/flowlight

-

v0.17.3macOS 15+UniversalGPL-3.0Sin telemetría

+

v0.17.7macOS 15+UniversalGPL-3.0Sin telemetría

@@ -127,7 +127,7 @@

Observa la actividad de red de tus apps.
-
El mapa de actividad de IA de Flowlight. Las tarjetas de agentes se conectan mediante herramientas atribuidas y puertas de enlace configuradas; la ruta observada de magpie a Flowlight se ramifica después hacia servicios LLM y dominios. El ancho de las cintas muestra el tráfico del período.

Ve la ruta real de IA: agentes, herramientas atribuidas, puertas de enlace configuradas, la etapa de proxy observada por Flowlight y destinos exactos. El ancho muestra el total recibido y enviado en el período seleccionado. Con datos de demostración.

@@ -135,7 +135,7 @@

Observa la actividad de red de tus apps.
-
La vista Agentes de IA de Flowlight. Claude Code, Cursor, Codex y un script de Python aparecen con sus proveedores de IA, el tráfico enviado a otros sitios y distintivos de riesgo por SMTP, SSH, FTP, una subida grande y una IP directa.

La vista Agentes de IA: el proveedor de modelo de cada agente y todo lo demás con lo que habló. Con datos de demostración.

@@ -179,11 +179,11 @@

Historial

¿Adónde ha ido todo? Del segundo al año, agrupado por ap

Alertas

¿Qué ha pintado mal? Alertas por app, explicables, con el destino y el umbral.

⌘5
-

Reglas

¿Qué he rechazado y se está cumpliendo? Cada regla, lo que ha bloqueado y un flujo con todas las decisiones.

⌘8 · Developer ID / ⌘7 · App Store
+

Políticas

¿Qué controles hay y están surtiendo efecto? Cada política, su estado real y un historial de actividad unificado.

⌘8 · Developer ID / ⌘7 · App Store

Preguntar

Preguntas en lenguaje corriente, respondidas con el historial, y debajo cada consulta que ejecutó.

⌘6
-

Fuentes

¿Están llegando datos, cuánto tiene nombre y qué canales de dispositivos están activos? Colector, Dispositivos y Cobertura en un solo lugar.

⌘9 · Developer ID / ⌘8 · App Store
+

Fuentes

¿Están llegando datos, qué canales de dispositivos están activos y qué puede contabilizar Flowlight? Una página desde Colector hasta Cobertura.

⌘9 · Developer ID / ⌘8 · App Store

Inspeccionar

Opcional. ¿Qué envió el agente en realidad y qué llamada de herramienta lo provocó? Peticiones descifradas, llamadas de herramientas, resultados.

⌘7 · Developer ID
@@ -405,16 +405,16 @@

Correlaciona las llamadas de herramientas compatibles con las peticiones de Mac. Arranca un agente desde un Terminal inspeccionado y el tráfico HTTP compatible del agente y de sus herramientas se descifra, con las llamadas de herramientas que pidió el modelo y los resultados que volvieron.

-
La vista Inspeccionar de Flowlight: una lista de peticiones descifradas de Claude Code con sus llamadas de herramientas, y el detalle de la subida de curl a paste.example, hecha por la llamada de herramienta Bash que la ejecutó, con las notas subidas en el cuerpo de la petición
¿Adónde han ido mis notas? La subida de curl a paste.example, rastreada hasta el comando Bash que pidió el modelo, con el cuerpo que envió.
-
La pestaña Herramientas de Claude Code: Bash, Read y dos herramientas MCP con sus descripciones y cuántas veces las llamó el modelo
Todas las herramientas que un agente ofrece al modelo. Los agentes declaran sus herramientas en cada turno, así que Flowlight las lista todas, señalando las que el modelo usó de verdad y las que no tocó nunca.
-
La pestaña Servidores MCP de Codex: un servidor sentry marcado como vía proveedor, su URL, la aprobación fijada en nunca, las herramientas permitidas y las que ofrece
Incluidos los que no se ven en el cable. Cuando un agente pide al proveedor que conecte con un servidor MCP, ese tráfico nunca llega a tu Mac. Su petición sí, así que el servidor, su URL, su ajuste de aprobación y sus herramientas siguen apareciendo.
@@ -460,7 +460,7 @@

Un monitor que no tienes que vigilar

-
El detalle de Claude Code en Flowlight: herramientas y servidores MCP con su número de llamadas y un fallo, una lista de permitidos y la pestaña Llamadas de herramientas con un git push fallido, una subida de curl a paste.example con su respuesta y su petición, un npm install, una incidencia de GitHub, una búsqueda en la documentación y una lectura de archivo, cada una con su resultado
Un agente, todas sus llamadas de herramientas. El tráfico del git, del curl y de los servidores MCP que arrancó Claude Code cuenta como de Claude Code. Con la inspección HTTPS activada, la pestaña Llamadas de herramientas muestra qué pidió el modelo, qué volvió y las peticiones que hizo cada herramienta: @@ -470,10 +470,10 @@

Un monitor que no tienes que vigilar

-
La vista Historial con barras por hora de datos recibidos y enviados y gráficos de anillo de apps, destinos y protocolos
Historial. Zoom del segundo al año, tres agrupaciones y gráficos que siguen legibles con cientos de apps.
-
La vista Alertas con alertas de exfiltración por un agente, de correo, de FTP, de actividad mientras no estás y de picos de tráfico
Alertas. Cada una nombra la app, el destino y la cifra que la activó.
@@ -589,7 +589,7 @@

Ten claro qué sale de tu Mac.

diff --git a/docs/es/privacy/index.html b/docs/es/privacy/index.html index 3b18c063..d06d8a11 100644 --- a/docs/es/privacy/index.html +++ b/docs/es/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -125,7 +125,7 @@

Peticiones de red que hace la app

- + @@ -189,7 +189,7 @@

Contacto

diff --git a/docs/es/threat-model/index.html b/docs/es/threat-model/index.html index cd6249f0..f25d6379 100644 --- a/docs/es/threat-model/index.html +++ b/docs/es/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -221,7 +221,7 @@

Informar de una vulnerabilidad

diff --git a/docs/fr/about/index.html b/docs/fr/about/index.html index 6a1adea2..3e76ee9c 100644 --- a/docs/fr/about/index.html +++ b/docs/fr/about/index.html @@ -48,7 +48,7 @@ - + @@ -152,7 +152,7 @@

Remerciements

diff --git a/docs/fr/docs/index.html b/docs/fr/docs/index.html index bdff2bac..ab14eb61 100644 --- a/docs/fr/docs/index.html +++ b/docs/fr/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Documentation

Utiliser Flowlight

-

Tout, du premier lancement au réglage des règles des agents. Flowlight 0.17.3, macOS 15 ou version ultérieure.

+

Tout, du premier lancement au réglage des règles des agents. Flowlight 0.17.7, macOS 15 ou version ultérieure.

@@ -170,11 +170,11 @@

Les vues

- - + +
PeticiónEnviada aQué contieneCuándo
Comprobación de actualizacionesapi.github.comUna petición HTTPS normal de la última versión; GitHub ve tu dirección IP y el agente de usuario Flowlight/<version>Al arrancar y a diario, desde la 0.1.2. Interruptor para desactivarla en Ajustes › General
Descarga de la actualizacióngithub.comLa imagen de disco y su archivo de suma de verificaciónSolo cuando pulsas Descargar y abrir
Actualización oficial del paquete de reglasflowlight.xinbetween.comUna solicitud HTTPS estándar al feed oficial fijo. El servidor ve tu dirección IP, el agente de usuario Flowlight/<version> y los validadores condicionales de caché; no recibe tráfico, ajustes de paquetes ni ningún identificador del dispositivoDespués del inicio, cuando corresponda, y luego como máximo una vez al día mientras todo funcione correctamente. Activada de forma predeterminada; interruptor para desactivarla y opción manual Buscar ahora en Reglas › Paquetes
Actualización oficial del paquete de reglasflowlight.xinbetween.comUna solicitud HTTPS estándar al feed oficial fijo. El servidor ve tu dirección IP, el agente de usuario Flowlight/<version> y los validadores condicionales de caché; no recibe tráfico, ajustes de paquetes ni ningún identificador del dispositivoDespués del inicio, cuando corresponda, y luego como máximo una vez al día mientras todo funcione correctamente. Activada de forma predeterminada; interruptor para desactivarla y opción manual Buscar ahora en Políticas › Paquetes gestionados
DNS inversoEl servidor DNS que tengas configuradoUna consulta del nombre registrado de una dirección IP, cuando no se ha visto ningún nombre de hostSegún haga falta, en el modo de captura del muestreador, que es el predeterminado hasta que la extensión de red esté en marcha. Sin interruptor para desactivarla: sin ella el muestreador no puede nombrar la mayoría de las conexiones. La extensión de red las nombra a partir del propio tráfico y no hace esta consulta
Consulta del propietario de la redTeam Cymru, por DNSDirecciones IP públicas sin nombre de host conocido. Las direcciones privadas y reservadas no salen nunca de tu MacActivada por defecto. Interruptor para desactivarla en Captura › Identificar a los propietarios de la red
Preguntar con un modelo alojadoEl proveedor que elijas: Anthropic, OpenAI, Google Gemini o un endpoint compatibleTu pregunta y los resultados que devuelvan las consultas de solo lectura de Flowlight que elija el modelo. La base de datos de tráfico en sí no se subeSolo cuando eliges un proveedor alojado y envías una pregunta. La pantalla Preguntar muestra las peticiones transmitidas
Alertes⌘5Tout ce qui a paru inhabituel, avec l’app, la destination et le chiffre qui a déclenché la règle.
Demander⌘6Des questions sur votre propre trafic en langage courant, auxquelles un modèle local ou hébergé répond à partir de l’historique enregistré.
Inspecter⌘7 · Developer IDFacultatif : les requêtes déchiffrées des agents que vous faites passer par Flowlight, et les appels d’outils qui les ont causées. La recherche va jusque dans les corps de requête et de réponse.
Règles⌘8 · Developer ID
⌘7 · App Store
Chaque liste d’autorisation et chaque refus que vous avez posés, ce que chacun couvre, et ce qu’il a réellement arrêté.
Sources⌘9 · Developer ID
⌘8 · App Store
La santé et la récupération du collecteur, la couverture des noms d’hôte, ainsi que l’activité facultative des caméras, du Bluetooth, de l’USB et du stockage dans trois onglets.
Politiques⌘8 · Developer ID
⌘7 · App Store
Chaque politique configurée, son effet réel et un historique d’activité unifié en lecture seule.
Sources⌘9 · Developer ID
⌘8 · App Store
La santé et la récupération du collecteur, les canaux d’appareils facultatifs et la couverture sur une seule page opérationnelle déroulante.
-

Les raccourcis suivent l’ordre visible de la barre latérale. Les versions Developer ID incluent Inspecter en ⌘7 ; les versions App Store l’omettent, donc Règles et Sources passent à ⌘7 et ⌘8.

-
Les raccourcis suivent l’ordre visible de la barre latérale. Les versions Developer ID incluent Inspecter en ⌘7 ; les versions App Store l’omettent, donc Politiques et Sources passent à ⌘7 et ⌘8.

+
L’écran En direct : une liste d’applications avec leurs débits d’envoi et de réception actuels et leur destination la plus active.
En direct, l’écran sur lequel Flowlight s’ouvre.

Dans Historique, survolez une barre pour ouvrir une fiche défilante qui suit le pointeur et affiche les apps contributrices ; les barres ne zooment pas au clic. Double-cliquez une ligne pour y restreindre l’historique.

@@ -215,7 +215,7 @@

Outils & serveurs MCP

dit plus que l’un ou l’autre seul, tandis qu’un domaine seulement configuré n’a jamais été joint, et qu’un domaine seulement observé n’était écrit nulle part où vous auriez pu le lire.

Chaque domaine propose deux refus : pour cet agent, ce qui couvre l’agent et les outils qu’il lance, ou - partout, ce qui couvre toutes les apps du Mac. Les deux écrivent une règle ordinaire, si bien que le flux des violations + partout, ce qui couvre toutes les apps du Mac. Les deux écrivent une règle ordinaire, si bien qu’Activité les signale et que l’éditeur de règles les explique — et, parce que ce sont des règles ordinaires, la simulation de l’éditeur vous dira ce que l’une d’elles aurait refusé avant que vous ne l’activiez.

Un type de domaine n’a pas de boutons. Un connecteur que le fournisseur de modèle joint de lui-même — déclaré dans le corps de la requête @@ -406,16 +406,16 @@

Réponses simulées

contactée : une simulation change la réponse, pas la possibilité d’établir la connexion, donc « l’API renvoie 500 » fonctionne alors que « l’API est complètement injoignable » non.

-

Règles

-

Une liste d’autorisation, c’est l’idée qu’un agent se fait des endroits où il peut aller. Une règle en est la version générale : elle nomme un +

Politiques

+

Politiques réunit tous les contrôles que Flowlight peut appliquer dans un même inventaire : règles ordinaires d’autorisation ou de blocage, listes d’autorisation des agents, packs gérés et, lorsque l’inspection HTTPS est disponible, garde-fous, simulations, réécritures de requêtes et transformations de réponses. Chaque ligne distingue l’état configuré de l’effet réel : appliquée, signalement uniquement, surveillée, en attente, programmée, suspendue, utilisée, en cours d’application, à vérifier, indisponible, incompatible ou en échec. La sélection d’une ligne ouvre l’éditeur contextuel auquel cette politique appartient ; Politiques ne remplace ni ne confond leurs différents modèles de consentement et de conservation.

+

Une liste d’autorisation, c’est l’idée qu’un agent se fait des endroits où il peut aller. Une règle ordinaire en est la version générale : elle nomme un sujet — une app, un agent, une destination, une URL, ou un couple formé de deux d’entre eux — et dit bloquer ou autoriser. Faites un clic droit sur n’importe quelle ligne dans En direct, Historique, Agents IA ou Inspecter pour en écrire une sur ce que vous regardez ; - toutes atterrissent dans le même éditeur, sur l’écran Règles, parce qu’une règle qui prendrait effet hors de votre vue - serait une règle que vous ne pourriez plus jamais retrouver.

+ chacune reste accessible dans Politiques et s’ouvre dans son éditeur de référence.

Des modèles. Vingt-deux, en cinq groupes — empêcher un agent d’atteindre des registres de paquets ou d’autres fournisseurs de modèles, fermer les voies habituelles par lesquelles les données partent (sites de partage de texte, collecteurs de webhooks, dépôts de fichiers anonymes, tunnels, DNS over HTTPS), l’analytique produit et les rapports de plantage, les politiques d’horaires de travail, et le « bloque-moi ça une heure, le temps que je comprenne - ce que c’est ». Parcourez-les depuis la barre d’outils de Règles, ou faites un clic droit sur une ligne n’importe où dans En direct, Historique, Agents IA ou Inspecter et + ce que c’est ». Parcourez-les depuis la barre d’outils de Politiques, ou faites un clic droit sur une ligne n’importe où dans En direct, Historique, Agents IA ou Inspecter et appliquez-en un à ce que vous regardez.

Chaque modèle annonce ce qu’il va faire avant de le faire. Tout ce qui écrit plus d’une règle ouvre une fiche listant chaque hôte et chaque horaire ; tout ce qui est permanent ouvre l’éditeur prérempli ; et seules les règles qui se défont d’elles-mêmes — une @@ -436,8 +436,7 @@

Règles

l’adresse et le port, rien des chemins, et uniquement en mode extension. L’inspection HTTPS refuse une requête : elle peut donc viser une URL et répondre par un statut que l’agent sait lire, au lieu d’une socket morte sans explication. L’échantillonneur nettop ne peut rien refuser du tout. Une règle que la configuration actuelle ne peut pas appliquer le dit dans la liste, plutôt que de rester là en faisant semblant.

-

Le second onglet est le flux des violations : chaque connexion qu’une règle a tranchée, avec l’app, sa destination, quelle - règle a tranché et s’il s’agissait d’un refus ou d’une exception qui l’a laissée passer.

+

La seconde vue est Activité, en lecture seule : elle réunit dans une même chronologie les décisions des règles ordinaires, les violations de listes d’autorisation, les événements des packs gérés et les effets distincts des politiques d’inspection. Elle adapte les enregistrements de référence existants au lieu de réécrire l’historique dans un modèle d’événement générique ; les versions App Store omettent les types de politiques dépendant de l’inspection et leur activité conservée.

Ce qu’elle aurait fait. Une règle de blocage est facile à écrire et effrayante à activer, parce que la première chose que l’on apprend d’ordinaire à son sujet, c’est ce qu’elle a cassé — et on l’apprend plus tard, parce que quelque chose @@ -458,7 +457,7 @@

Règles

d’historique a été testée sur deux heures d’historique.

Packs gérés de publicité et de traqueurs

-

Règles › Packs contient trois listes officielles de noms d’hôte : Publicité, Suivi intersites et Confidentialité et télémétrie. Elles sont visibles dès le départ et toutes désactivées par défaut. Le blocage nécessite l’Extension réseau ; l’échantillonneur nettop ne peut qu’observer.

+

Politiques › Packs gérés contient trois listes officielles de noms d’hôte : Publicité, Suivi intersites et Confidentialité et télémétrie. Elles sont visibles dès le départ et toutes désactivées par défaut. Le blocage nécessite l’Extension réseau ; l’échantillonneur nettop ne peut qu’observer.

Uniquement des mises à jour officielles et signées. Flowlight consulte une source HTTPS fixe unique pour rechercher des catalogues plus récents signés par l’éditeur. Les vérifications automatiques sont activées par défaut et peuvent être désactivées ici. Une signature authentifie le contenu, mais ne l’active pas : un nouveau pack reste désactivé et, si les destinations changent, l’application d’un pack activé est suspendue jusqu’à ce que vous examiniez la nouvelle version exacte et appuyiez sur Enregistrer. Pendant une panne, le contenu accepté peut rester disponible comme dernière version valide connue, visiblement signalée comme obsolète. Il s’agit de blocages réseau au niveau des noms d’hôte : ils ne peuvent pas supprimer les vides dans le DOM ou la mise en page, les superpositions du navigateur, les fenêtres contextuelles générées par des scripts ni les fenêtres d’applications natives.

Quand l’inspection HTTPS achemine une app par le proxy de Flowlight, celui-ci décide des règles de destination ordinaires et des packs gérés activés d’après l’identité d’origine de l’app, avant d’ouvrir toute connexion au serveur d’origine. Pour les tunnels CONNECT épinglés ou jamais déchiffrés, cela fonctionne par nom d’hôte ; les listes d’autorisation des agents restent limitées au chemin direct par l’Extension réseau. Si le propriétaire ou le nom d’hôte est inconnu, la connexion est autorisée.

@@ -507,8 +506,8 @@

Garde-fous des agents : bloquer un outil, un serveur ou

Ce n’est pas un bac à sable. Un outil refusé cesse d’être proposé et cesse d’obtenir une réponse. Un agent qui a encore un shell peut faire à la main ce que l’outil aurait fait. Flowlight l’enregistrera ; il ne prétend pas l’empêcher.

-

Sources : collecteur, couverture et appareils

-

Sources réunit la configuration opérationnelle. Son résumé de santé indique si le trafic arrive, quelle part est nommée et quels canaux d’appareils facultatifs sont actifs ; toute étape inachevée mène directement à Sources › Collecteur.

+

Sources : collecteur, appareils et couverture

+

Sources est une seule page opérationnelle déroulante : d’abord Collecteur, puis Appareils et Couverture dans des sections clairement titrées. Les avis d’état, les réponses de Demander, les fenêtres restaurées et les liens des captures d’écran font défiler jusqu’à la section concernée et la mettent brièvement en évidence sans masquer les autres.

Flowlight enregistre le trafic de deux façons, au choix dans Sources › Collecteur. Elles diffèrent par ce qu’elles peuvent manquer, ce qu’il vaut la peine de comprendre avant de compter sur l’une ou l’autre.

@@ -706,7 +705,7 @@

Questions & réponses

Fenêtre fermée, Flowlight ne fait presque rien : il tient à jour les débits de la barre des menus et écrit une ligne par seconde. L’extension réseau coûte moins que l’échantillonneur de secours, qui lance un processus nettop éphémère chaque seconde.
Peut-il bloquer des connexions ?
-
Oui, avec l’extension réseau. La liste d’autorisation d’un agent peut refuser autant qu’avertir, une règle peut bloquer une app, +
Oui, avec l’extension réseau. La liste d’autorisation d’un agent peut refuser autant qu’avertir, une règle peut bloquer une app, une destination ou une URL aussi longtemps que vous le dites, et un garde-fou peut retirer un outil à un agent avant que son modèle ne le voie. Rien ne bloque avant que vous le demandiez, et chaque refus porte sa sortie de secours.
Ai-je besoin de l’extension réseau ?
@@ -761,7 +760,7 @@

Limites

diff --git a/docs/fr/index.html b/docs/fr/index.html index bd083475..493af6cd 100644 --- a/docs/fr/index.html +++ b/docs/fr/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

Voyez l’activité réseau de vos apps.

brew install --cask xinbetween/tap/flowlight

-

v0.17.3macOS 15+UniverselGPL-3.0Sans télémétrie

+

v0.17.7macOS 15+UniverselGPL-3.0Sans télémétrie

@@ -127,7 +127,7 @@

Voyez l’activité réseau de vos apps.
-
La carte d’activité IA de Flowlight. Les cartes des agents passent par des outils attribués et des passerelles configurées ; la route observée de magpie vers Flowlight se ramifie ensuite vers les services LLM et les domaines. La largeur des rubans indique le trafic de la période.

Voyez la route IA réelle : agents, outils attribués, passerelles configurées, étape proxy observée par Flowlight et destinations exactes. La largeur indique le total reçu et envoyé pendant la période sélectionnée. Avec des données de démonstration.

@@ -135,7 +135,7 @@

Voyez l’activité réseau de vos apps.
-
La vue Agents IA de Flowlight. Claude Code, Cursor, Codex et un script Python y figurent avec leurs fournisseurs d’IA, le trafic envoyé ailleurs et des badges de risque pour SMTP, SSH, FTP, un envoi volumineux et une IP brute.

La vue Agents IA : le fournisseur de modèle de chaque agent, et tout le reste de ce qu’il a contacté. Avec des données de démonstration.

@@ -179,11 +179,11 @@

Historique

Où tout cela est-il parti ? De la seconde à l’ann

Alertes

Qu’est-ce qui clochait ? Des alertes explicables, par app, avec la destination et le seuil.

⌘5
-

Règles

Qu’ai-je refusé, et est-ce bien appliqué ? Chaque règle, ce qu’elle a bloqué, et le flux de toutes les décisions.

⌘8 · Developer ID / ⌘7 · App Store
+

Politiques

Quels contrôles existent, et sont-ils effectifs ? Chaque politique, son état réel et un historique d’activité unifié.

⌘8 · Developer ID / ⌘7 · App Store

Demander

Des questions en langage courant, auxquelles l’historique répond — avec la liste des requêtes exécutées juste en dessous.

⌘6
-

Sources

Les données arrivent-elles, quelle part est nommée et quels canaux d’appareils sont actifs ? Collecteur, Appareils et Couverture au même endroit.

⌘9 · Developer ID / ⌘8 · App Store
+

Sources

Les données arrivent-elles, quels canaux d’appareils sont actifs et de quoi Flowlight peut-il rendre compte ? Une seule page, du Collecteur à la Couverture.

⌘9 · Developer ID / ⌘8 · App Store

Inspecter

Facultatif. Qu’a réellement envoyé l’agent, et quel appel d’outil en est la cause ? Requêtes déchiffrées, appels d’outils, résultats.

⌘7 · Developer ID
@@ -405,16 +405,16 @@

Reliez les appels d’outils pris en charge aux requêtes réseau

Mac. Lancez un agent depuis un Terminal inspecté et le trafic HTTP pris en charge de l’agent et de ses outils est déchiffré, avec les appels d’outils que le modèle a demandés et les résultats qui lui sont revenus.

-
La vue Inspecter de Flowlight : une liste de requêtes Claude Code déchiffrées avec leurs appels d’outils, et le détail de l’envoi de curl vers paste.example, effectué par l’appel d’outil Bash qui l’a lancé, avec les notes envoyées dans le corps de la requête
Où sont parties mes notes ? L’envoi de curl vers paste.example, remonté jusqu’à la commande Bash demandée par le modèle, avec le corps transmis.
-
L’onglet Outils de Claude Code : Bash, Read et deux outils MCP avec leurs descriptions et le nombre d’appels du modèle pour chacun
Tous les outils qu’un agent propose au modèle. Les agents déclarent leurs outils à chaque tour, donc Flowlight les liste tous, en distinguant ceux que le modèle a vraiment utilisés de ceux qu’il n’a jamais touchés.
-
L’onglet Serveurs MCP de Codex : un serveur sentry marqué via le fournisseur, son URL, l’approbation réglée sur jamais, les outils autorisés et les outils qu’il propose
Y compris ceux qu’on ne voit pas passer sur le réseau. Quand un agent demande au fournisseur de se connecter à un serveur MCP, ce trafic ne passe jamais par votre Mac. Sa requête, si : le serveur, son URL, son réglage d’approbation et ses outils apparaissent quand même.
@@ -460,7 +460,7 @@

Un moniteur que vous n’avez pas à surveiller

-
Le détail de Claude Code dans Flowlight : Outils et serveurs MCP avec le nombre d’appels et un échec, une liste d’autorisation, et l’onglet Appels d’outils listant un git push en échec, un envoi curl vers paste.example avec sa réponse et sa requête, un npm install, un ticket GitHub, une recherche dans la documentation et une lecture de fichier, chacun avec son résultat
Un agent, tous ses appels d’outils. Le trafic des git, curl et serveurs MCP lancés par Claude Code compte comme celui de Claude Code. Avec l’inspection HTTPS activée, l’onglet Appels d’outils montre ce que le modèle a demandé, ce qui est revenu et les requêtes émises par chaque outil : @@ -470,10 +470,10 @@

Un moniteur que vous n’avez pas à surveiller

-
La vue Historique avec les barres horaires des données reçues et envoyées et des anneaux pour les apps, les destinations et les protocoles
Historique. Zoom de la seconde à l’année, trois regroupements, des graphiques qui restent lisibles avec des centaines d’apps.
-
La vue Alertes listant des alertes d’exfiltration par un agent, d’e-mail, de FTP, d’activité pendant une absence et de pic de trafic
Alertes. Chacune nomme l’app, la destination et le chiffre qui l’a déclenchée.
@@ -589,7 +589,7 @@

Sachez ce qui quitte votre Mac.

diff --git a/docs/fr/privacy/index.html b/docs/fr/privacy/index.html index dcb3e9ae..ed04a2b4 100644 --- a/docs/fr/privacy/index.html +++ b/docs/fr/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -125,7 +125,7 @@

Les requêtes réseau que fait l’app

- + @@ -189,7 +189,7 @@

Contact

diff --git a/docs/fr/threat-model/index.html b/docs/fr/threat-model/index.html index 8401ad79..00d12c4c 100644 --- a/docs/fr/threat-model/index.html +++ b/docs/fr/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -224,7 +224,7 @@

Signaler une vulnérabilité

diff --git a/docs/index.html b/docs/index.html index d869c362..4f3d25e9 100644 --- a/docs/index.html +++ b/docs/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

See your apps' network activity.
brew install --cask xinbetween/tap/flowlight

-

v0.17.3macOS 15+UniversalGPL-3.0No telemetry

+

v0.17.7macOS 15+UniversalGPL-3.0No telemetry

@@ -127,7 +127,7 @@

See your apps' network activity.
-
Flowlight's AI activity map. Agent cards connect through attributed tools and configured gateways; the observed magpie to Flowlight route then branches to LLM services and domains, with ribbon width showing period traffic volume.

See the real AI route: agents, attributed tools, configured gateways, Flowlight's observed proxy stage, and exact destinations. Ribbon width shows total received plus sent traffic for the selected period. Shown with demo data.

@@ -135,7 +135,7 @@

See your apps' network activity.
-
Flowlight's AI Agents view. Claude Code, Cursor, Codex and a Python script are listed with their AI providers, traffic sent elsewhere and risk badges for SMTP, SSH, FTP, a large upload and a raw IP.

The AI Agents view: each agent's model provider, and everything else it talked to. Shown with demo data.

@@ -143,7 +143,7 @@

See your apps' network activity.
-
The AI Agents view offering to always monitor Windsurf, and confirming Flowlight is keeping Claude Code and Cursor routed through it on every launch, each with a one-click Stop Monitoring.

Always monitor an agent: Flowlight writes the proxy into the agent's own settings so it routes through on every launch, and says what it's keeping — stop any of it in a click. Shown with demo data.

@@ -187,11 +187,11 @@

History

Where did it all go? From 15 minutes through all history, gro

Alerts

What looked wrong? Explainable, per-app alerts with the destination and the threshold.

⌘5
-

Rules

What have I refused, and is it happening? Every rule, what each has blocked, and a feed of every decision.

⌘8 · Developer ID / ⌘7 · App Store
+

Policies

What controls exist, and are they effective? Every policy, its real state, and a unified activity trail.

⌘8 · Developer ID / ⌘7 · App Store

Ask

Questions in plain language, answered from the history — with every query it ran listed underneath.

⌘6
-

Sources

Is data arriving, how much is named, and which device channels are active? Collector, Devices and Coverage in one place.

⌘9 · Developer ID / ⌘8 · App Store
+

Sources

Is data arriving, which device channels are active, and what can Flowlight account for? One page: Collector, Devices, then Coverage.

⌘9 · Developer ID / ⌘8 · App Store

Inspect

Optional. What did the agent actually send, and which tool call made it? Decrypted requests, tool calls, results.

⌘7 · Developer ID
@@ -427,16 +427,16 @@

Correlate supported tool calls with network requests

Mac. Start an agent from an inspected Terminal and supported HTTP traffic from the agent and its tools is decrypted, with tool calls the model asked for and the results that went back.

-
Flowlight's Inspect view: a list of decrypted Claude Code requests with their tool calls, and the detail of curl's upload to paste.example, made by the Bash tool call that ran it, with the uploaded notes in the request body
Where did my notes go? curl's upload to paste.example, traced to the Bash command the model asked for, with the body it sent.
-
The Tools tab for Claude Code: Bash, Read and two MCP tools with descriptions and how often the model called each
Every tool an agent offers the model. Agents declare their tools on each turn, so Flowlight lists them all, with the ones the model actually used and the ones it never touched.
-
The MCP servers tab for Codex: a sentry server marked via provider, its URL, approval set to never, allowed tools and the tools it offers
Including the ones you can't see on the wire. When an agent asks the provider to connect to an MCP server, that traffic never reaches your Mac. Its request does, so the server, its URL, its approval setting and its tools still show up.
@@ -482,7 +482,7 @@

A monitor you don't have to monitor

-
Claude Code's detail in Flowlight: Tools and MCP servers with call counts and one failure, an allowlist, and the Tool calls tab listing a failed git push, a curl upload to paste.example with its response and request, npm install, a GitHub issue, a docs search and a file read, each with its result
One agent, every tool call. Traffic from the git, curl and MCP servers Claude Code started counts as Claude Code's. With HTTPS inspection on, the Tool calls tab shows what the model asked for, what came back, and the requests each tool made: @@ -492,10 +492,10 @@

A monitor you don't have to monitor

-
History view with hourly received and sent bars and donut charts of apps, destinations and protocols
History. Preset ranges, a draggable navigator, three groupings, and charts that stay readable with hundreds of apps.
-
Alerts view listing agent exfiltration, email, FTP, while-away and traffic spike alerts
Alerts. Each one names the app, the destination and the number that tripped it.
@@ -612,7 +612,7 @@

Know what leaves your Mac.

diff --git a/docs/it/about/index.html b/docs/it/about/index.html index cecbcdc5..f46b48c3 100644 --- a/docs/it/about/index.html +++ b/docs/it/about/index.html @@ -48,7 +48,7 @@ - + @@ -152,7 +152,7 @@

Ringraziamenti

diff --git a/docs/it/docs/index.html b/docs/it/docs/index.html index 4591d5d8..80351357 100644 --- a/docs/it/docs/index.html +++ b/docs/it/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Documentazione

Usare Flowlight

-

Tutto, dal primo avvio alla messa a punto delle regole per gli agenti. Flowlight 0.17.3, macOS 15 o successivo.

+

Tutto, dal primo avvio alla messa a punto delle regole per gli agenti. Flowlight 0.17.7, macOS 15 o successivo.

@@ -170,11 +170,11 @@

Le viste

- - + +
RequêteEnvoyée àCe qu’elle contientQuand
Recherche de mise à jourapi.github.comUne requête HTTPS standard pour la dernière version ; GitHub voit votre adresse IP et l’agent utilisateur Flowlight/<version>Au lancement et une fois par jour, depuis 0.1.2. Interrupteur d’arrêt dans Réglages › Général
Téléchargement de mise à jourgithub.comL’image disque et son fichier de somme de contrôleSeulement quand vous cliquez sur Télécharger & ouvrir
Mise à jour du pack de règles officielflowlight.xinbetween.comUne requête HTTPS standard vers le flux officiel fixe. Le serveur voit votre adresse IP, l’agent utilisateur Flowlight/<version> et les validateurs conditionnels du cache ; il ne reçoit ni trafic, ni réglages des packs, ni identifiant d’appareilAprès le démarrage, lorsqu’une vérification est due, puis au maximum une fois par jour tant que le service fonctionne normalement. Activée par défaut ; interrupteur de désactivation et option manuelle Rechercher maintenant dans Règles › Packs
Mise à jour du pack de règles officielflowlight.xinbetween.comUne requête HTTPS standard vers le flux officiel fixe. Le serveur voit votre adresse IP, l’agent utilisateur Flowlight/<version> et les validateurs conditionnels du cache ; il ne reçoit ni trafic, ni réglages des packs, ni identifiant d’appareilAprès le démarrage, lorsqu’une vérification est due, puis au maximum une fois par jour tant que le service fonctionne normalement. Activée par défaut ; interrupteur de désactivation et option manuelle Rechercher maintenant dans Politiques › Packs gérés
DNS inverseLe résolveur DNS que vous avez configuréUne recherche du nom enregistré d’une adresse IP, quand aucun nom d’hôte n’a été observéAu besoin, dans le mode de capture par échantillonnage, qui est celui par défaut jusqu’à ce que l’extension réseau tourne. Pas d’interrupteur d’arrêt : sans elle, l’échantillonneur ne peut pas nommer la plupart des connexions. L’extension réseau les nomme à partir du trafic lui-même et ne fait pas cette recherche
Recherche du propriétaire de réseauTeam Cymru, par DNSDes adresses IP publiques sans nom d’hôte connu. Les adresses privées et réservées ne quittent jamais votre MacActivé par défaut. Interrupteur d’arrêt dans Sources › Collecteur › Identifier les propriétaires de réseau
Demander avec un modèle hébergéLe fournisseur que vous choisissez : Anthropic, OpenAI, Google Gemini ou un point de terminaison compatibleVotre question et les résultats renvoyés par les requêtes Flowlight en lecture seule choisies par le modèle. La base de données de trafic elle-même n’est pas envoyéeSeulement quand vous choisissez un fournisseur hébergé et soumettez une question. L’écran Demander montre les requêtes transmises
Avvisi⌘5Tutto ciò che è sembrato insolito, con l'app, la destinazione e il numero che ha fatto scattare la regola.
Chiedi⌘6Domande sul tuo traffico in linguaggio naturale, a cui un modello locale o ospitato risponde a partire dalla cronologia registrata.
Ispeziona⌘7 · Developer IDOpzionale: le richieste decifrate degli agenti che instradi attraverso Flowlight e le chiamate agli strumenti che le hanno prodotte. La ricerca entra nei corpi di richieste e risposte.
Regole⌘8 · Developer ID
⌘7 · App Store
Ogni elenco consentiti e ogni rifiuto che hai impostato, cosa copre ciascuno e cosa ha fermato davvero.
Origini⌘9 · Developer ID
⌘8 · App Store
Stato del Collector, canali dei dispositivi e copertura dei nomi in tre schede.
Criteri⌘8 · Developer ID
⌘7 · App Store
Ogni criterio configurato, se è davvero effettivo e una cronologia unificata delle attività in sola lettura.
Origini⌘9 · Developer ID
⌘8 · App Store
Stato e ripristino del Collector, canali facoltativi dei dispositivi e copertura in un'unica pagina operativa a scorrimento.
-

Le scorciatoie seguono l'ordine visibile della barra laterale. Le versioni Developer ID includono Ispeziona su ⌘7; quelle App Store lo omettono, quindi Regole e Origini passano a ⌘7 e ⌘8.

-
Le scorciatoie seguono l'ordine visibile della barra laterale. Le versioni Developer ID includono Ispeziona su ⌘7; quelle App Store lo omettono, quindi Criteri e Origini passano a ⌘7 e ⌘8.

+
La schermata In diretta: un elenco di applicazioni con le loro velocità attuali di invio e ricezione e la destinazione più trafficata.
In diretta, la schermata con cui Flowlight si apre.

In Cronologia, passa il puntatore su una barra per aprire una scheda scorrevole che segue il cursore e mostra le app che hanno contribuito; le barre non ingrandiscono al clic. Fai doppio clic su una riga per limitare la cronologia a essa.

@@ -404,16 +404,20 @@

Risposte simulate

contattata: una simulazione cambia la risposta, non la possibilità di connettersi, quindi «l'API restituisce 500» funziona mentre «l'API è del tutto irraggiungibile» no.

-

Regole

-

Un elenco consentiti è l'idea che un singolo agente ha di dove può andare. Una regola è la versione generale: indica un +

Criteri

+

Criteri riunisce in un solo inventario tutti i controlli che Flowlight può applicare: normali regole di consenso o blocco, elenchi consentiti + degli agenti, pacchetti gestiti e, dove è disponibile l'ispezione HTTPS, protezioni, simulazioni, riscritture delle richieste e trasformazioni delle + risposte. Ogni riga distingue lo stato configurato dall'effetto reale: applicazione attiva, sola segnalazione, monitoraggio, attesa, pianificato, + in pausa, esaurito, in corso di applicazione, revisione necessaria, non disponibile, incompatibile o non riuscito. Selezionando una riga si apre l'editor + contestuale responsabile di quel criterio; Criteri non sostituisce né confonde i distinti modelli di consenso e persistenza.

+

Un elenco consentiti è l'idea che un singolo agente ha di dove può andare. Una normale regola è la versione generale: indica un soggetto — un'app, un agente, una destinazione, un URL o una coppia di due di questi — e dice blocca o consenti. Fai clic con il tasto destro su una riga in In diretta, Cronologia, Agenti IA o Ispeziona per scriverne una su ciò che stai guardando; - tutte finiscono nello stesso editor della schermata Regole, perché una regola che entra in vigore fuori dalla vista - sarebbe una regola che non potresti più ritrovare.

+ ognuna resta reperibile in Criteri e si apre nel proprio editor autorevole.

Modelli. Ventidue, in cinque gruppi: impedire a un agente di raggiungere i registri di pacchetti o altri provider di modelli, chiudere le vie più comuni per cui i dati escono (siti di paste, raccoglitori di webhook, servizi anonimi per caricare file, tunnel, DNS over HTTPS), analisi di prodotto e segnalazione degli arresti anomali, politiche sull'orario di lavoro, e il classico «blocca questo per un'ora mentre capisco - cos'è». Sfogliali dalla barra strumenti di Regole, oppure fai clic con il tasto destro su una riga in In diretta, Cronologia, Agenti IA o Ispeziona e + cos'è». Sfogliali dalla barra strumenti di Criteri, oppure fai clic con il tasto destro su una riga in In diretta, Cronologia, Agenti IA o Ispeziona e applicane uno a ciò che stai guardando.

Ogni modello dice cosa farà prima di farlo. Tutto ciò che scrive più di una regola apre una scheda con l'elenco di ogni host e orario; tutto ciò che è permanente apre l'editor già compilato; e solo le regole che si annullano da sole — un'ora, @@ -434,8 +438,9 @@

Regole

l'indirizzo e la porta, nulla dei percorsi, e solo in modalità estensione. L'ispezione HTTPS rifiuta una singola richiesta, quindi può corrispondere a un URL e rispondere con uno stato che l'agente può leggere, invece di un socket morto senza spiegazioni. Il campionatore nettop non può rifiutare nulla. Una regola che la configurazione attuale non può applicare lo dice nell'elenco, invece di starsene lì con l'aria di essere in vigore.

-

Il secondo pannello è il flusso delle violazioni: ogni connessione decisa da una regola, con l'app, dove era diretta, quale - regola ha deciso e se si è trattato di un rifiuto o di un'eccezione che l'ha lasciata passare.

+

La seconda vista è Attività, in sola lettura: riunisce in un'unica cronologia le decisioni delle regole comuni, le violazioni degli elenchi consentiti, + gli eventi dei pacchetti gestiti e gli effetti distinti dei criteri di ispezione. Usa i record originali, ciascuno autorevole nel proprio ambito, invece di + riscrivere la cronologia in un unico modello generico di evento; le versioni Store omettono i tipi di criterio che dipendono dall'ispezione e le relative attività conservate.

Cosa avrebbe fatto. Una regola di blocco è facile da scrivere e spaventosa da attivare, perché la prima cosa che normalmente scopri di una regola così è che cosa ha rotto — e lo scopri dopo, da qualcosa che smette di funzionare altrove. @@ -456,7 +461,7 @@

Regole

testata su due ore di storico.

Pacchetti gestiti per pubblicità e tracker

-

Regole › Pacchetti contiene tre elenchi ufficiali di nomi host: Pubblicità, Tracciamento tra siti e Privacy e telemetria. Sono visibili fin dall’inizio e tutti disattivati per impostazione predefinita. Il blocco richiede l’Estensione di rete; il campionatore nettop può solo osservare.

+

Criteri › Pacchetti gestiti contiene tre elenchi ufficiali di nomi host: Pubblicità, Tracciamento tra siti e Privacy e telemetria. Sono visibili fin dall’inizio e tutti disattivati per impostazione predefinita. Il blocco richiede l’Estensione di rete; il campionatore nettop può solo osservare.

Solo aggiornamenti ufficiali e firmati. Flowlight controlla un’unica fonte HTTPS fissa per trovare cataloghi più recenti firmati dall’editore. I controlli automatici sono attivi per impostazione predefinita e possono essere disattivati qui. Una firma autentica il contenuto, ma non lo attiva: un nuovo pacchetto resta disattivato e, se cambiano le destinazioni, l’applicazione di un pacchetto attivo viene sospesa finché non esamini l’esatta nuova versione e premi Salva. Durante un’interruzione, i contenuti accettati possono restare disponibili come ultima versione valida nota, visibilmente indicata come obsoleta. Si tratta di blocchi di rete a livello di nome host: non possono eliminare spazi vuoti nel DOM o nel layout, sovrapposizioni del browser, popup generati da script o finestre di app native.

Quando l’ispezione HTTPS instrada un’app attraverso il proxy di Flowlight, questo decide le normali regole di destinazione e i pacchetti gestiti attivi usando l’identità originale dell’app prima di aprire qualsiasi connessione al server di origine. Per i tunnel CONNECT con pinning o mai decifrati, ciò funziona in base al nome host; gli elenchi consentiti degli agenti restano limitati al percorso diretto attraverso l’Estensione di rete. Se il proprietario o il nome host è sconosciuto, la connessione viene consentita.

@@ -505,7 +510,10 @@

Protezioni per gli agenti: bloccare uno strumento, un server

Questa non è una sandbox. Uno strumento rifiutato non viene più offerto e non riceve più risposta. Un agente che ha ancora una shell può fare a mano ciò che avrebbe fatto lo strumento. Flowlight lo registra; non pretende di impedirlo.

-

Origini: Collector e motori

+

Origini: Collector, dispositivi e copertura

+

Origini è un'unica pagina operativa a scorrimento: prima il Collector, poi Dispositivi e Copertura come sezioni chiaramente + contrassegnate. Gli avvisi di stato, le risposte di Chiedi, le finestre ripristinate e i collegamenti usati per gli screenshot scorrono fino alla sezione pertinente + e la mettono brevemente in evidenza senza nascondere le altre.

Flowlight registra il traffico in uno di due modi, da scegliere in Origini › Collector. Differiscono per ciò che possono perdere, e vale la pena capirlo prima di affidarsi all'uno o all'altro.

@@ -703,7 +711,7 @@

Domande e risposte

Con la finestra chiusa Flowlight fa quasi nulla: mantiene le velocità nella barra dei menu e scrive una riga al secondo. L'estensione di rete costa meno del campionatore di riserva, che avvia un breve processo nettop ogni secondo.
Può bloccare le connessioni?
-
Sì, con l'estensione di rete. L'elenco consentiti di un agente può rifiutare oltre che avvisare, una regola può bloccare un'app, +
Sì, con l'estensione di rete. L'elenco consentiti di un agente può rifiutare oltre che avvisare, una regola può bloccare un'app, una destinazione o un URL per il tempo che dici tu, e una protezione può togliere uno strumento a un agente prima che venga offerto al suo modello. Nulla blocca finché non glielo chiedi, e ogni rifiuto porta con sé la sua via d'uscita.
Mi serve l'estensione di rete?
@@ -758,7 +766,7 @@

Limitazioni

diff --git a/docs/it/index.html b/docs/it/index.html index 791acc2f..927c4b03 100644 --- a/docs/it/index.html +++ b/docs/it/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

Vedi la rete delle tue app.
Ca

brew install --cask xinbetween/tap/flowlight

-

v0.17.3macOS 15+UniversaleGPL-3.0Nessuna telemetria

+

v0.17.7macOS 15+UniversaleGPL-3.0Nessuna telemetria

@@ -127,7 +127,7 @@

Vedi la rete delle tue app.
Ca
-
La mappa dell’attività IA di Flowlight. Le schede degli agenti passano attraverso strumenti attribuiti e gateway configurati; il percorso osservato da magpie a Flowlight si dirama poi verso servizi LLM e domini. La larghezza dei nastri mostra il traffico del periodo.

Vedi il percorso IA reale: agenti, strumenti attribuiti, gateway configurati, lo stadio proxy osservato da Flowlight e le destinazioni esatte. La larghezza mostra il totale ricevuto e inviato nel periodo selezionato. Con dati dimostrativi.

@@ -135,7 +135,7 @@

Vedi la rete delle tue app.
Ca
-
La vista Agenti IA di Flowlight. Claude Code, Cursor, Codex e uno script Python sono elencati con i loro provider IA, il traffico inviato altrove e i contrassegni di rischio per SMTP, SSH, FTP, un caricamento di grandi dimensioni e un IP grezzo.

La vista Agenti IA: il provider del modello di ogni agente e tutto il resto con cui ha parlato. Mostrata con dati dimostrativi.

@@ -179,11 +179,11 @@

Cronologia

Dove è finito tutto? Dal secondo all'anno, per app, desti

Avvisi

Cosa non tornava? Avvisi per app, spiegati, con la destinazione e la soglia.

⌘5
-

Regole

Cosa ho rifiutato, e sta funzionando? Ogni regola, che cosa ha bloccato e il flusso di tutte le decisioni.

⌘8 · Developer ID / ⌘7 · App Store
+

Criteri

Quali controlli esistono, e sono effettivi? Ogni criterio, il suo stato reale e una cronologia unificata delle attività.

⌘8 · Developer ID / ⌘7 · App Store

Chiedi

Domande in linguaggio naturale, con risposte tratte dalla cronologia e sotto l'elenco di tutte le query eseguite.

⌘6
-

Origini

I dati stanno arrivando, quanti hanno un nome e quali canali dei dispositivi sono attivi? Collector, Dispositivi e Copertura in un unico posto.

⌘9 · Developer ID / ⌘8 · App Store
+

Origini

I dati stanno arrivando, quali canali dei dispositivi sono attivi e che cosa può contabilizzare Flowlight? Un'unica pagina, dal Collector alla Copertura.

⌘9 · Developer ID / ⌘8 · App Store

Ispeziona

Opzionale. Cosa ha inviato davvero l'agente, e quale chiamata a uno strumento l'ha prodotta? Richieste decifrate, chiamate agli strumenti, risultati.

⌘7 · Developer ID
@@ -405,16 +405,16 @@

Collega le chiamate agli strumenti supportate alle richieste di rete

sul tuo Mac. Avvia un agente da un Terminale ispezionato e il traffico HTTP supportato dell'agente e dei suoi strumenti viene decifrato, con le chiamate agli strumenti richieste dal modello e i risultati tornati indietro.

-
La vista Ispeziona di Flowlight: un elenco di richieste decifrate di Claude Code con le loro chiamate agli strumenti e il dettaglio del caricamento di curl verso paste.example, prodotto dalla chiamata allo strumento Bash che lo ha eseguito, con le note caricate nel corpo della richiesta
Dove sono finite le mie note? Il caricamento di curl verso paste.example, risalendo al comando Bash richiesto dal modello, con il corpo che ha inviato.
-
Il pannello Strumenti per Claude Code: Bash, Read e due strumenti MCP con le descrizioni e quante volte il modello ha chiamato ciascuno
Ogni strumento che un agente offre al modello. Gli agenti dichiarano i loro strumenti a ogni turno, così Flowlight li elenca tutti, indicando quelli che il modello ha davvero usato e quelli che non ha mai toccato.
-
Il pannello Server MCP per Codex: un server sentry contrassegnato come «tramite provider», il suo URL, approvazione impostata su «mai», gli strumenti consentiti e quelli che offre
Compresi quelli che non vedi passare in rete. Quando un agente chiede al provider di collegarsi a un server MCP, quel traffico non arriva mai al tuo Mac. La sua richiesta sì, così il server, il suo URL, la sua impostazione di approvazione e i suoi strumenti compaiono comunque.
@@ -460,7 +460,7 @@

Un monitoraggio che non devi monitorare

-
Il dettaglio di Claude Code in Flowlight: strumenti e server MCP con il numero di chiamate e un errore, un elenco consentiti e il pannello Chiamate agli strumenti con un git push fallito, un caricamento curl verso paste.example con la sua risposta e la sua richiesta, npm install, un issue su GitHub, una ricerca nella documentazione e la lettura di un file, ciascuno con il proprio risultato
Un agente, tutte le chiamate agli strumenti. Il traffico di git, curl e dei server MCP avviati da Claude Code conta come traffico di Claude Code. Con l'ispezione HTTPS attiva, il pannello Chiamate agli strumenti mostra che cosa ha chiesto il modello, che cosa è tornato indietro e le richieste fatte da ogni strumento: @@ -470,10 +470,10 @@

Un monitoraggio che non devi monitorare

-
La vista Cronologia con le barre orarie dei dati ricevuti e inviati e i grafici ad anello di app, destinazioni e protocolli
Cronologia. Zoom dal secondo all'anno, tre raggruppamenti, grafici che restano leggibili con centinaia di app.
-
La vista Avvisi con avvisi di esfiltrazione da parte di un agente, e-mail, FTP, attività mentre sei via e picco di traffico
Avvisi. Ognuno indica l'app, la destinazione e il numero che lo ha fatto scattare.
@@ -589,7 +589,7 @@

Sappi che cosa esce dal tuo Mac.

diff --git a/docs/it/privacy/index.html b/docs/it/privacy/index.html index 10fa7286..a1ef0c9e 100644 --- a/docs/it/privacy/index.html +++ b/docs/it/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -125,7 +125,7 @@

Richieste di rete che fa l'app

- + @@ -189,7 +189,7 @@

Contatti

diff --git a/docs/it/threat-model/index.html b/docs/it/threat-model/index.html index 75068625..e0361838 100644 --- a/docs/it/threat-model/index.html +++ b/docs/it/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -223,7 +223,7 @@

Segnalare una vulnerabilità

diff --git a/docs/ja/about/index.html b/docs/ja/about/index.html index cd963463..07d3e337 100644 --- a/docs/ja/about/index.html +++ b/docs/ja/about/index.html @@ -48,7 +48,7 @@ - + @@ -153,7 +153,7 @@

謝辞

diff --git a/docs/ja/docs/index.html b/docs/ja/docs/index.html index 719a211e..1e2fb7b4 100644 --- a/docs/ja/docs/index.html +++ b/docs/ja/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

ドキュメント

Flowlight の使い方

-

初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.17.3、macOS 15 以降。

+

初回起動からエージェントのルールの調整まで、すべてここに。Flowlight 0.17.7、macOS 15 以降。

@@ -168,11 +168,11 @@

各ビュー

- - + +
RichiestaInviata aCosa contieneQuando
Controllo aggiornamentiapi.github.comUna normale richiesta HTTPS per l'ultima versione; GitHub vede il tuo indirizzo IP e lo user agent Flowlight/<version>All'avvio e una volta al giorno, dalla 0.1.2. Interruttore per disattivarla in Impostazioni › Generali
Download dell'aggiornamentogithub.comL'immagine disco e il suo file di checksumSolo quando fai clic su Scarica e apri
Aggiornamento ufficiale del pacchetto di regoleflowlight.xinbetween.comUna richiesta HTTPS standard al feed ufficiale fisso. Il server vede il tuo indirizzo IP, lo user agent Flowlight/<version> e i validatori condizionali della cache; non riceve traffico, impostazioni dei pacchetti o identificatori del dispositivoDopo l’avvio, quando previsto, e successivamente non più di una volta al giorno durante il normale funzionamento. Attivo per impostazione predefinita; interruttore per disattivarlo e opzione manuale Controlla ora in Regole › Pacchetti
Aggiornamento ufficiale del pacchetto di regoleflowlight.xinbetween.comUna richiesta HTTPS standard al feed ufficiale fisso. Il server vede il tuo indirizzo IP, lo user agent Flowlight/<version> e i validatori condizionali della cache; non riceve traffico, impostazioni dei pacchetti o identificatori del dispositivoDopo l’avvio, quando previsto, e successivamente non più di una volta al giorno durante il normale funzionamento. Attivo per impostazione predefinita; interruttore per disattivarlo e opzione manuale Controlla ora in Criteri › Pacchetti gestiti
DNS inversoIl resolver DNS che hai configuratoUna ricerca del nome registrato di un indirizzo IP, quando non è stato rilevato alcun nome hostQuando serve, nella modalità di acquisizione a campionamento, che è quella predefinita finché l'estensione di rete non è in funzione. Nessun interruttore per disattivarla: senza di essa il campionatore non può dare un nome alla maggior parte delle connessioni. L'estensione di rete le nomina dal traffico stesso e non fa questa ricerca
Ricerca del proprietario della reteTeam Cymru, via DNSIndirizzi IP pubblici senza nome host noto. Gli indirizzi privati e riservati non lasciano mai il tuo MacAttiva per impostazione predefinita. Interruttore per disattivarla in Acquisizione › Identifica i proprietari della rete
Chiedi con un modello ospitatoIl provider che selezioni: Anthropic, OpenAI, Google Gemini o un endpoint compatibileLa tua domanda e i risultati restituiti dalle query di sola lettura di Flowlight selezionate dal modello. Il database del traffico non viene caricatoSolo quando selezioni un provider ospitato e invii una domanda. La schermata Chiedi mostra le richieste trasmesse
アラート⌘5いつもと違って見えたものすべて。アプリ、接続先、そしてルールのしきい値を超えた数値とともに。
質問⌘6自分のトラフィックについての、ふつうの言葉での質問。記録された履歴から、ローカルまたはホスト型のモデルが答えます。
インスペクト⌘7 · Developer ID任意。Flowlight を経由させたエージェントの復号済みリクエストと、その裏にあるツール呼び出し。検索はリクエストとレスポンスの本文にも届きます。
ルール⌘8 · Developer ID
⌘7 · App Store
設定したすべての許可リストと拒否。それぞれが何を対象にし、実際に何を止めたか。
ソース⌘9 · Developer ID
⌘8 · App Store
コレクターの状態、デバイスのチャンネル、名前付けのカバー範囲を 3 つのタブで表示。
ポリシー⌘8 · Developer ID
⌘7 · App Store
設定されたすべてのポリシー、その実際の適用状況、そして統合された読み取り専用のアクティビティ履歴。
ソース⌘9 · Developer ID
⌘8 · App Store
コレクターの状態と復旧、任意で有効にするデバイスチャンネル、カバー範囲を、1 つのスクロール可能な運用ページに表示。
-

ショートカットは表示中のサイドバー順です。Developer ID 版は ⌘7 にインスペクトがあります。App Store 版では省かれるため、ルールとソースは ⌘7 と ⌘8 になります。

-
ショートカットは表示中のサイドバー順です。Developer ID 版は ⌘7 にインスペクトがあります。App Store 版では省かれるため、ポリシーとソースは ⌘7 と ⌘8 になります。

+
ライブビュー。アプリの一覧に、現在の送信と受信の速度、そして最も忙しい接続先が並んでいます。
ライブ。Flowlight が最初に開く画面です。

履歴では、棒にポインタを重ねると、原因のアプリを示すスクロール可能なカードがポインタに追従します。棒をクリックしても拡大しません。行をダブルクリックすると、その項目だけに履歴を絞り込めます。

@@ -211,7 +211,7 @@

ツールと MCP サーバ

書かれていてしかも実際に接続もあったということは、どちらか一方だけよりも多くを語るからです。設定されているだけの ドメインには一度も届いておらず、観測されただけのドメインは、前もって読めるどこにも書かれていませんでした。

ドメインごとに拒否の仕方が二つあります。このエージェントに対してはエージェントとそれが起動するツールを覆い、どこでもは - この Mac のすべてのアプリを覆います。どちらもふつうのルールを書きます。ですから違反の履歴がそれを報告し、ルールのエディタが + この Mac のすべてのアプリを覆います。どちらもふつうのルールを書きます。ですからアクティビティがそれを報告し、ルールのエディタが それを説明します。そしてふつうのルールなので、有効にする前に、エディタのシミュレーションがそのルールなら何を拒否していたかを 教えてくれます。

ボタンの付かないドメインが一種類あります。モデルプロバイダが自分で接続するコネクタです(リクエストの本文で宣言され、 @@ -383,14 +383,19 @@

モック応答

接続先への接続そのものは行われるということです。モックが変えるのは答えであって、接続できるかどうかではありません。つまり「API が 500 を返す」は再現できますが、 「API にまったく到達できない」は再現できません。

-

ルール

-

許可リストは、1 つのエージェントについて「どこへ行ってよいか」を定めたものです。ルールはその一般版で、 +

ポリシー

+

ポリシーは、Flowlight が適用できるすべての制御をまとめた一覧です。通常の許可・ブロックルール、エージェントの + 許可リスト、管理対象パック、さらに HTTPS インスペクションを利用できる場合は、ガードレール、モック、リクエストの書き換え、レスポンスの + 変換も含まれます。各行では、設定された状態と実際の効果を区別し、適用中、報告のみ、監視中、待機中、スケジュール待ち、一時停止、使用済み、 + 適用処理中、要確認、利用不可、互換性なし、失敗のいずれかを示します。行を選ぶと、そのポリシーを管理する本来のエディタが開きます。 + ポリシー画面が、それぞれ異なる同意や保存の仕組みを置き換えたり、曖昧にしたりすることはありません。

+

許可リストは、1 つのエージェントについて「どこへ行ってよいか」を定めたものです。通常のルールはその一般版で、 対象(アプリ、エージェント、接続先、URL、またはそのうち 2 つの組み合わせ)を指定し、ブロックか 許可を決めます。ライブ、履歴、AI エージェント、インスペクトのどの行でも右クリックすれば、いま見ているものについてルールを書けます。 - そのどれもがルール画面の同じエディタに集まります。見えないところで効き始めるルールは、二度と見つけられないルールになってしまうからです。

+ どこから作成したルールもポリシー画面で見つけられ、選ぶとそのルールを管理する本来のエディタが開きます。

テンプレート。22 種類あり、5 つのグループに分かれています。エージェントがパッケージレジストリやほかのモデルプロバイダへ届かないようにするもの、 データが出ていきやすい経路をふさぐもの(ペーストサイト、Webhook の受け口、匿名のファイル置き場、トンネル、DNS over HTTPS)、プロダクト分析とクラッシュレポート、 - 勤務時間のポリシー、そして「作業のあいだ 1 時間だけこれをブロックして、何なのか調べる」という単発のものです。ルールのツールバーから一覧を見るか、 + 勤務時間のポリシー、そして「作業のあいだ 1 時間だけこれをブロックして、何なのか調べる」という単発のものです。ポリシーのツールバーから一覧を見るか、 ライブ、履歴、AI エージェント、インスペクトのどこかで行を右クリックして、いま見ているものに当てはめてください。

どのテンプレートも、実行する前に何をするかを述べます。2 つ以上のルールを書くものは、すべてのホストとスケジュールを並べたカードを開きます。恒久的なものは あらかじめ入力されたエディタを開きます。そして自分で元に戻るルールだけ(1 時間、またはこの回の Flowlight の実行中だけ)が、先に示されることなく @@ -408,8 +413,9 @@

ルール

しかも機能拡張モードのときだけです。HTTPS インスペクションは 1 つのリクエストを拒否するので、URL に一致させられますし、理由も告げずに死んだソケットの代わりに、 エージェントが読めるステータスで応答できます。nettop サンプラーは何も拒否できません。現在の構成では実行できないルールは、効いているように見せかけて並ぶのではなく、 一覧にそう表示されます。

-

2 つめのタブは違反の履歴です。ルールが判断したすべての接続について、アプリ、向かおうとしていた先、どのルールが判断したか、 - そしてそれが拒否だったのか、通過を許した例外だったのかが並びます。

+

2 つめのビューは読み取り専用のアクティビティです。通常のルールによる判断、許可リスト違反、管理対象パックのイベント、 + インスペクションポリシー固有の効果を、1 つの時系列にまとめます。すでにある正式な記録をそのまま参照し、履歴を汎用イベントへ + 書き換えることはありません。App Store 版では、インスペクションに依存するポリシーの種類と、それに保存されたアクティビティは表示されません。

このルールが何をしていたか。ブロックのルールは、書くのは簡単でも、有効にするのは怖いものです。そのルールについて最初に分かるのは、 ふつう何を壊したかであり、しかもそれが分かるのは後になってから、どこか別の場所で何かが失敗したときだからです。ルールのエディタは、そこに先に答えます。 @@ -427,7 +433,7 @@

ルール

履歴でテストしたルールは、2 時間分の履歴でテストしたということです。

管理対象の広告・トラッカーパック

-

ルール › パックには、広告、サイト横断トラッキング、プライバシーとテレメトリという3つの公式ホスト名リストがあります。これらは最初から表示され、すべてデフォルトでオフになっています。ブロックにはネットワーク機能拡張が必要です。nettop サンプラーは監視のみ可能です。

+

ポリシー › 管理対象パックには、広告、サイト横断トラッキング、プライバシーとテレメトリという3つの公式ホスト名リストがあります。これらは最初から表示され、すべてデフォルトでオフになっています。ブロックにはネットワーク機能拡張が必要です。nettop サンプラーは監視のみ可能です。

公式の署名付きアップデートのみ。 Flowlight は、公開元が署名した新しいカタログがないか、1つの固定 HTTPS ソースを確認します。自動確認はデフォルトでオンになっており、ここでオフにできます。署名はコンテンツを認証しますが、有効化はしません。新しいパックはオフのままで、宛先が変更された場合は、正確な新バージョンを確認して保存を押すまで、有効なパックの適用が停止されます。障害中も、承認済みコンテンツが、古いことを明示した既知の正常な最終版として保持される場合があります。これらはホスト名単位のネットワークブロックです。DOM やレイアウトの空白、ブラウザのオーバーレイ、スクリプトによるポップアップ、ネイティブアプリのウインドウは除去できません。

HTTPS インスペクションがアプリを Flowlight のプロキシ経由にすると、接続先の通常ルールと有効な管理対象パックは、接続先サーバへの接続が開かれる前に、元のアプリの識別情報を使って判定されます。証明書がピン留めされている、または復号されない CONNECT トンネルでも、ホスト名によって判定できます。エージェントの許可リストは引き続き、ネットワーク機能拡張を直接通る経路だけに適用されます。所有者またはホスト名が不明な場合は、接続を許可します。

@@ -470,7 +476,10 @@

エージェントのガードレール:ツール、サー

これはサンドボックスではありません。拒否したツールは提示されなくなり、呼び出しにも応答しなくなります。しかしシェルを使えるエージェントは、 そのツールがしていたことを手作業で行えます。Flowlight はそれを記録しますが、防げるふりはしません。

-

ソース:コレクターとエンジン

+

ソース:コレクター、デバイス、カバー範囲

+

ソースは、コレクター、デバイス、カバー範囲の順に、見出しで区切って並べた 1 つのスクロール可能な運用ページです。 + 状況の通知、質問への回答、復元されたウインドウ、スクリーンショットからのリンクは、ほかのセクションを隠さず、該当するセクションまで + スクロールして短時間強調表示します。

Flowlight がトラフィックを記録する方法は 2 つあり、ソース › コレクターで選びます。何を取りこぼしうるかが違うので、どちらかに頼る前に 知っておく価値があります。

@@ -653,7 +662,7 @@

質問と回答

ウインドウを閉じているあいだ、Flowlight はほとんど何もしません。メニューバーの速度を保ち、1 秒に 1 行書き込むだけです。ネットワーク機能拡張のほうが、 1 秒ごとに短命な nettop プロセスを起動する代替のサンプラーよりも負荷が低いです。
接続をブロックできますか?
-
はい、ネットワーク機能拡張があれば。エージェントの許可リストは警告だけでなく拒否もでき、ルールはアプリ、接続先、URL を +
はい、ネットワーク機能拡張があれば。エージェントの許可リストは警告だけでなく拒否もでき、ルールはアプリ、接続先、URL を 好きな期間ブロックでき、ガードレールはモデルに提示される前にエージェントからツールを取り上げられます。 あなたが指示するまで何もブロックされず、どの拒否にも抜け道が添えてあります。
ネットワーク機能拡張は必要ですか?
@@ -708,7 +717,7 @@

制限事項

diff --git a/docs/ja/index.html b/docs/ja/index.html index 1e4a2930..cb342516 100644 --- a/docs/ja/index.html +++ b/docs/ja/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

アプリの通信が見える。
brew install --cask xinbetween/tap/flowlight

-

v0.17.3macOS 15+ユニバーサルGPL-3.0テレメトリなし

+

v0.17.7macOS 15+ユニバーサルGPL-3.0テレメトリなし

@@ -127,7 +127,7 @@

アプリの通信が見える。
-
Flowlight の AI アクティビティマップ。エージェントカードは関連付けられたツールと設定済みゲートウェイを経由し、観測された magpie から Flowlight への経路が LLM サービスとドメインへ分岐します。帯の幅は期間内の通信量を示します。

実際の AI 経路を確認できます。エージェント、関連付けられたツール、設定済みゲートウェイ、Flowlight が観測したプロキシ段階、正確な接続先を表示。帯の幅は選択期間の受信量と送信量の合計です。デモデータで表示しています。

@@ -135,7 +135,7 @@

アプリの通信が見える。
-
Flowlight の AI エージェントビュー。Claude Code、Cursor、Codex、Python スクリプトが、利用している AI プロバイダ、ほかへ送ったトラフィック、SMTP・SSH・FTP・大量送信・生 IP のリスクバッジとともに並んでいます。

AI エージェントビュー。各エージェントのモデルプロバイダと、それ以外に通信した相手のすべて。デモデータで表示しています。

@@ -179,11 +179,11 @@

履歴

結局どこへ行った?秒から年まで、アプリ・

アラート

何がおかしかった?理由がわかるアプリ単位のアラートに、接続先としきい値を添えて。

⌘5
-

ルール

何を拒否し、それは効いている?すべてのルール、それぞれがブロックしたもの、判定の履歴。

⌘8 · Developer ID / ⌘7 · App Store
+

ポリシー

どんな制御があり、実際に効いている?すべてのポリシー、その実際の状態、統合されたアクティビティ履歴。

⌘8 · Developer ID / ⌘7 · App Store

質問

ふつうの言葉で聞けば、履歴から答えます。実行したクエリもすべて下に並びます。

⌘6
-

ソース

データは届いているか、どれだけ名前が付いているか、どのデバイス経路が有効か。コレクター、デバイス、カバー範囲を 1 か所に。

⌘9 · Developer ID / ⌘8 · App Store
+

ソース

データは届いているか、どのデバイス経路が有効か、Flowlight はどこまで把握できるか。コレクターからカバー範囲までを 1 ページに。

⌘9 · Developer ID / ⌘8 · App Store

インスペクト

任意。エージェントは実際に何を送り、どのツール呼び出しが原因だった?復号したリクエスト、ツール呼び出し、結果。

⌘7 · Developer ID
@@ -404,16 +404,16 @@

対応するツール呼び出しとネットワークリクエストを結 インスペクト対象のターミナルからエージェントを起動すれば、エージェントとそのツールの対応 HTTP 通信が復号され、モデルが要求した ツール呼び出しと、返っていった結果まで見えます。

-
Flowlight のインスペクトビュー。復号された Claude Code のリクエストとそのツール呼び出しの一覧、そして paste.example への curl の送信の詳細。実行元となった Bash のツール呼び出しと、リクエスト本文に入った送信済みのメモが表示されています
あのメモはどこへ行った?paste.example への curl の送信を、モデルが要求した Bash コマンドまでたどり、 送られた本文まで示します。
-
Claude Code のツールタブ。Bash、Read と 2 つの MCP ツールが、説明とモデルが呼び出した回数とともに並んでいます
エージェントがモデルに渡すツールのすべて。エージェントはターンごとにツールを宣言するので、Flowlight はそれを全部並べ、 モデルが実際に使ったものと、一度も触らなかったものを示します。
-
Codex の MCP サーバタブ。プロバイダ経由と記された sentry サーバ、その URL、承認は「しない」に設定、許可されたツールと提供するツールが表示されています
回線上では見えないものまで。エージェントがプロバイダ側に MCP サーバへの接続を任せると、その通信は Mac を通りません。 でも依頼そのものは通るので、サーバ、その URL、承認の設定、提供するツールはやはり表示されます。
@@ -459,7 +459,7 @@

監視しなくていい監視ツール

-
Flowlight での Claude Code の詳細。呼び出し回数と 1 件の失敗が付いたツールと MCP サーバ、許可リスト、そしてツール呼び出しタブに、失敗した git push、応答とリクエストを伴う paste.example への curl 送信、npm install、GitHub の issue、ドキュメント検索、ファイル読み取りが、それぞれの結果とともに並んでいます
1 つのエージェント、すべてのツール呼び出し。Claude Code が起動した git や curl、MCP サーバの通信は、Claude Code のものとして数えます。 HTTPS インスペクションをオンにすると、ツール呼び出しタブに、モデルが何を要求し、何が返り、各ツールがどんなリクエストを出したかが並びます。 @@ -469,10 +469,10 @@

監視しなくていい監視ツール

-
履歴ビュー。1 時間ごとの受信と送信の棒グラフ、アプリ・接続先・プロトコルのドーナツグラフが表示されています
履歴。秒から年までのズーム、3 つのグループ化、アプリが数百あっても読めるグラフ。
-
アラートビュー。エージェントの情報流出、メール、FTP、離席中、トラフィック急増のアラートが並んでいます
アラート。どれもアプリ、接続先、きっかけになった数値を示します。
@@ -588,7 +588,7 @@

Mac から何が出ていくのかを知る。

diff --git a/docs/ja/privacy/index.html b/docs/ja/privacy/index.html index 11dcb21d..dcdae705 100644 --- a/docs/ja/privacy/index.html +++ b/docs/ja/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -123,7 +123,7 @@

アプリが行うネットワークリクエスト

- + @@ -187,7 +187,7 @@

連絡先

diff --git a/docs/ja/threat-model/index.html b/docs/ja/threat-model/index.html index c7b64500..9099d356 100644 --- a/docs/ja/threat-model/index.html +++ b/docs/ja/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -222,7 +222,7 @@

脆弱性を報告する

diff --git a/docs/ko/about/index.html b/docs/ko/about/index.html index 7d4b4821..122a95fa 100644 --- a/docs/ko/about/index.html +++ b/docs/ko/about/index.html @@ -48,7 +48,7 @@ - + @@ -153,7 +153,7 @@

감사

diff --git a/docs/ko/docs/index.html b/docs/ko/docs/index.html index 06fc0120..d4baa836 100644 --- a/docs/ko/docs/index.html +++ b/docs/ko/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

문서

Flowlight 사용하기

-

첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.17.3, macOS 15 이상.

+

첫 실행부터 에이전트 규칙 조정까지 전부. Flowlight 0.17.7, macOS 15 이상.

@@ -169,11 +169,11 @@

여러 뷰

- - + +
リクエスト送信先含まれるものタイミング
アップデートの確認api.github.com最新リリースを尋ねる通常の HTTPS リクエスト。GitHub が見るのは、IP アドレスと Flowlight/<version> というユーザーエージェントです起動時と 1 日 1 回(0.1.2 以降)。オフのスイッチは設定 › 一般
アップデートのダウンロードgithub.comディスクイメージと、そのチェックサムファイル「ダウンロードして開く」をクリックしたときだけ
公式ルールパックのアップデートflowlight.xinbetween.com固定された公式フィードへの標準 HTTPS リクエストです。サーバには、IP アドレス、Flowlight/<version>ユーザーエージェント、条件付きキャッシュバリデータが表示されます。トラフィック、パック設定、デバイス識別子は送信されません起動後、確認期限に達している場合に実行し、その後は正常稼働中に1日最大1回実行します。デフォルトでオンです。ルール › パックでオフにするか、今すぐ確認を手動で実行できます
公式ルールパックのアップデートflowlight.xinbetween.com固定された公式フィードへの標準 HTTPS リクエストです。サーバには、IP アドレス、Flowlight/<version>ユーザーエージェント、条件付きキャッシュバリデータが表示されます。トラフィック、パック設定、デバイス識別子は送信されません起動後、確認期限に達している場合に実行し、その後は正常稼働中に1日最大1回実行します。デフォルトでオンです。ポリシー › 管理対象パックでオフにするか、「今すぐ確認」を手動で実行できます
逆引き DNSあなたが設定した DNS リゾルバホスト名が見つからなかった場合の、IP アドレスの登録名の照会必要に応じて、サンプラーのキャプチャモードで。ネットワーク機能拡張が動き出すまではこれがデフォルトです。オフのスイッチはありません。これがないとサンプラーはほとんどの接続に名前を付けられないからです。ネットワーク機能拡張はトラフィック自身から名前を付けるので、これを行いません
ネットワーク所有者の照会Team Cymru(DNS 経由)ホスト名がわからないパブリック IP アドレス。プライベートアドレスと予約アドレスは、この Mac から決して出ませんデフォルトでオン。オフのスイッチはキャプチャ › ネットワーク所有者を特定
ホスト型モデルでの質問あなたが選んだプロバイダ:Anthropic、OpenAI、Google Gemini、または互換のエンドポイントあなたの質問と、モデルが選んだ読み取り専用の Flowlight の問い合わせが返した結果。トラフィックのデータベース自体はアップロードされませんホスト型のプロバイダを選び、質問を送信したときだけ。質問の画面には、送信されたリクエストが表示されます
알림⌘5이상해 보인 모든 것. 앱과 목적지, 그리고 규칙의 기준을 넘긴 수치와 함께.
질문⌘6내 트래픽에 대한 평범한 말로 된 질문. 기록된 이력에서 로컬 모델이나 호스팅 모델이 답합니다.
검사⌘7 · Developer ID선택 사항. Flowlight를 거치도록 지정한 에이전트의 복호화된 요청과 그 뒤에 있는 도구 호출. 검색은 요청·응답 본문까지 들어갑니다.
규칙⌘8 · Developer ID
⌘7 · App Store
설정해 둔 모든 허용 목록과 거부, 각각이 무엇을 포괄하는지, 그리고 실제로 무엇을 막았는지.
소스⌘9 · Developer ID
⌘8 · App Store
컬렉터 상태, 기기 채널, 이름 지정 포착 범위를 세 개의 탭에서 표시합니다.
정책⌘8 · Developer ID
⌘7 · App Store
설정된 모든 정책과 실제 적용 상태, 그리고 하나로 모은 읽기 전용 활동 기록.
소스⌘9 · Developer ID
⌘8 · App Store
컬렉터 상태와 복구, 선택형 기기 채널, 포착 범위를 하나의 스크롤 가능한 운영 페이지에서 봅니다.
-

단축키는 보이는 사이드바 순서를 따릅니다. Developer ID 빌드에는 ⌘7의 검사가 있고, App Store 빌드에는 없으므로 규칙과 소스가 ⌘7과 ⌘8로 이동합니다.

-
단축키는 보이는 사이드바 순서를 따릅니다. Developer ID 빌드에는 ⌘7의 검사가 있고, App Store 빌드에는 없으므로 정책과 소스가 ⌘7과 ⌘8로 이동합니다.

+
실시간 뷰: 앱 목록에 각각의 현재 보내기·받기 속도와 가장 바쁜 목적지가 함께 나열되어 있습니다.
실시간, Flowlight가 처음 여는 화면.

기록에서 막대에 포인터를 올리면 포인터를 따라다니는 스크롤 가능한 카드에 기여한 앱이 표시됩니다. 막대를 클릭해 확대하지는 않습니다. 행을 두 번 클릭하면 기록을 그 항목으로 좁힐 수 있습니다.

@@ -213,7 +213,7 @@

도구와 MCP 서버

것은 둘 중 하나보다 더 많은 것을 말해 줍니다. 설정만 되어 있는 도메인은 한 번도 닿은 적이 없고, 관찰만 된 도메인은 미리 읽어 볼 수 있는 어디에도 적혀 있지 않았습니다.

도메인마다 거부하는 방법이 두 가지입니다. 이 에이전트에 대해는 에이전트와 그것이 시작하는 도구를 덮고, - 어디서나는 이 Mac의 모든 앱을 덮습니다. 둘 다 평범한 규칙을 씁니다. 그래서 위반 기록이 그것을 + 어디서나는 이 Mac의 모든 앱을 덮습니다. 둘 다 평범한 규칙을 씁니다. 그래서 활동이 그것을 보고하고 규칙 편집기가 그것을 설명하며, 평범한 규칙이기 때문에 편집기의 시뮬레이션이 규칙을 켜기 전에 그것이 무엇을 거부했을지 알려 줍니다.

버튼이 붙지 않는 도메인이 한 종류 있습니다. 모델 제공업체가 스스로 닿는 커넥터입니다(요청 본문에 선언되고 제공업체 쪽에서 @@ -402,15 +402,19 @@

모의 응답

그리고 목적지에는 여전히 연결합니다. 모의 응답은 답을 바꾸는 것이고 연결 자체가 되는지를 바꾸는 것이 아니므로, “API가 500을 돌려준다”는 만들 수 있지만 “API에 아예 닿지 않는다”는 만들 수 없습니다.

-

규칙

-

허용 목록은 에이전트 하나가 어디까지 갈 수 있는지에 대한 이야기입니다. 규칙은 그것의 일반형입니다. +

정책

+

정책은 Flowlight가 적용할 수 있는 모든 제어를 한데 모은 목록입니다. 일반 허용·차단 규칙, 에이전트 허용 목록, + 관리형 팩, 그리고 HTTPS 검사를 사용할 수 있을 때는 가드레일, 모의 응답, 요청 다시 쓰기, 응답 변환까지 포함합니다. 각 행은 설정된 상태와 + 실제 효력을 구분해 적용 중, 보고만 함, 감시 중, 대기 중, 예약됨, 일시 중지, 사용 완료, 적용 중, 검토 필요, 사용 불가, 호환되지 않음, + 실패를 표시합니다. 행을 선택하면 그 정책을 관리하는 원래 편집기가 열립니다. 정책 화면이 서로 다른 동의 방식과 저장 방식을 대신하거나 + 뒤섞지는 않습니다.

+

허용 목록은 에이전트 하나가 어디까지 갈 수 있는지에 대한 이야기입니다. 일반 규칙은 그것의 일반형입니다. 대상, 즉 앱이나 에이전트, 목적지, URL, 또는 그 둘의 조합을 지목하고 차단인지 허용인지 정합니다. 실시간이나 기록, AI 에이전트, 검사에서 아무 행이나 오른쪽 클릭하면 지금 보고 있는 것에 - 대한 규칙을 쓸 수 있습니다. 어느 경로로 만들어도 모두 규칙 화면의 같은 편집기로 모입니다. 보이지 않는 곳에서 - 효력을 갖는 규칙은 다시는 찾을 수 없는 규칙이 되기 때문입니다.

+ 대한 규칙을 쓸 수 있습니다. 어느 경로에서 만들었든 정책 화면에서 찾을 수 있고, 선택하면 그 규칙을 관리하는 원래 편집기가 열립니다.

템플릿. 스물두 개가 다섯 묶음으로 있습니다. 에이전트가 패키지 레지스트리나 다른 모델 제공업체에 닿지 못하게 막는 것, 데이터가 나가는 흔한 경로를 닫는 것(붙여넣기 사이트, 웹훅 수집기, 익명 파일 업로드, 터널, DNS over HTTPS), 제품 분석과 - 크래시 보고, 업무 시간 정책, 그리고 “이게 뭔지 알아내는 동안 한 시간만 막아 두기” 같은 일회성 규칙입니다. 규칙 툴바에서 + 크래시 보고, 업무 시간 정책, 그리고 “이게 뭔지 알아내는 동안 한 시간만 막아 두기” 같은 일회성 규칙입니다. 정책 툴바에서 둘러보거나, 실시간·기록·AI 에이전트·검사의 아무 행에서 오른쪽 클릭해 지금 보고 있는 것에 적용하세요.

각 템플릿은 무엇을 할지 먼저 알려줍니다. 규칙을 두 개 이상 쓰는 템플릿은 모든 호스트와 일정을 나열한 카드를 열고, 영구적인 것은 미리 채워진 편집기를 열며, 스스로 사라지는 규칙, 즉 한 시간짜리나 이번 Flowlight 실행 동안만 유효한 @@ -432,8 +436,9 @@

규칙

경로는 모르고, 확장 모드에서만 동작합니다. HTTPS 검사는 요청 하나를 거부하므로 URL까지 일치시킬 수 있고, 이유도 없이 죽은 소켓 대신 에이전트가 읽을 수 있는 상태 코드로 답할 수 있습니다. nettop 샘플러는 아무것도 거부하지 못합니다. 현재 설정으로 실행할 수 없는 규칙은 적용된 척 앉아 있지 않고 목록에서 그렇다고 밝힙니다.

-

두 번째 탭은 위반 기록입니다. 규칙이 판정한 모든 연결이, 앱과 향하던 곳, 어떤 규칙이 판정했는지, 그리고 - 그것이 거부였는지 통과시킨 예외였는지와 함께 나옵니다.

+

두 번째 뷰는 읽기 전용 활동입니다. 일반 규칙 판정, 허용 목록 위반, 관리형 팩 이벤트, 검사 정책 고유의 효과를 + 하나의 시간순 기록으로 모읍니다. 기존의 원본 기록을 그대로 연결해 보여 줄 뿐, 기록을 하나의 일반 이벤트 형식으로 다시 쓰지 않습니다. + App Store 빌드에서는 검사에 의존하는 정책 유형과 그 정책에 보관된 활동을 표시하지 않습니다.

이 규칙이 했을 일. 차단 규칙은 쓰기는 쉽고 켜기는 무섭습니다. 어떤 규칙에 대해 보통 가장 먼저 알게 되는 것은 그것이 무엇을 망가뜨렸는지이고, 그것도 나중에 다른 어딘가에서 무언가가 실패할 때 알게 되기 때문입니다. 규칙 편집기는 그 질문에 @@ -451,7 +456,7 @@

규칙

있었던 가장 오래된 트래픽도 함께 밝힙니다. 두 시간짜리 이력으로 대조한 규칙은 두 시간짜리 이력으로 대조한 것입니다.

관리형 광고 및 추적기 팩

-

규칙 › 팩에는 광고, 사이트 간 추적, 개인정보 보호 및 원격 측정이라는 세 가지 공식 호스트 이름 목록이 있습니다. 처음부터 표시되며 모두 기본적으로 꺼져 있습니다. 차단하려면 네트워크 확장 프로그램이 필요하며, nettop 샘플러는 관찰만 할 수 있습니다.

+

정책 › 관리형 팩에는 광고, 사이트 간 추적, 개인정보 보호 및 원격 측정이라는 세 가지 공식 호스트 이름 목록이 있습니다. 처음부터 표시되며 모두 기본적으로 꺼져 있습니다. 차단하려면 네트워크 확장 프로그램이 필요하며, nettop 샘플러는 관찰만 할 수 있습니다.

공식 서명 업데이트만 허용됩니다. Flowlight는 게시자가 서명한 최신 카탈로그가 있는지 하나의 고정 HTTPS 소스를 확인합니다. 자동 확인은 기본적으로 켜져 있으며 여기에서 끌 수 있습니다. 서명은 콘텐츠를 인증하지만 활성화하지는 않습니다. 새 팩은 꺼진 상태로 유지되며, 대상이 변경되면 활성화된 팩은 정확한 새 버전을 검토하고 저장을 누를 때까지 적용되지 않습니다. 서비스 중단 중에도 승인된 콘텐츠가 눈에 띄게 오래된 것으로 표시된 마지막 정상 버전으로 유지될 수 있습니다. 이는 호스트 이름 수준의 네트워크 차단이므로 DOM이나 레이아웃의 빈 공간, 브라우저 오버레이, 스크립트로 생성된 팝업 또는 네이티브 앱 창은 제거할 수 없습니다.

HTTPS 검사가 앱을 Flowlight의 프록시로 보내면, 일반 대상 규칙과 활성화된 관리형 팩은 원본 서버 연결이 열리기 전에 원래 앱의 신원을 사용해 결정됩니다. 인증서가 고정되었거나 복호화되지 않는 CONNECT 터널도 호스트 이름으로 판정할 수 있습니다. 에이전트 허용 목록은 계속 네트워크 확장을 직접 지나는 경로에만 적용됩니다. 소유자나 호스트 이름을 알 수 없으면 연결을 허용합니다.

@@ -500,7 +505,9 @@

에이전트 가드레일: 도구나 서버, 리소스를

이것은 샌드박스가 아닙니다. 거부된 도구는 더 이상 제공되지도, 응답되지도 않습니다. 하지만 셸이 남아 있는 에이전트는 그 도구가 하던 일을 직접 할 수 있습니다. Flowlight는 그것을 기록합니다. 막아 주는 척하지는 않습니다.

-

소스: 컬렉터와 엔진

+

소스: 컬렉터, 기기 및 포착 범위

+

소스는 컬렉터, 기기, 포착 범위를 차례로 뚜렷한 제목 아래 배치한 하나의 스크롤 가능한 운영 페이지입니다. + 상태 알림, 질문 답변, 복원된 창, 스크린샷 링크는 다른 섹션을 숨기지 않고 관련 섹션으로 스크롤한 뒤 잠시 강조합니다.

Flowlight는 소스 › 컬렉터에서 고르는 두 가지 방식 중 하나로 트래픽을 기록합니다. 두 방식은 무엇을 놓칠 수 있는지가 다르므로, 어느 쪽에 의지하기 전에 알아 둘 만합니다.

@@ -693,7 +700,7 @@

질문과 답

창을 닫아 두면 Flowlight는 거의 아무 일도 하지 않습니다. 메뉴 막대의 속도를 유지하고 1초에 한 행을 씁니다. 네트워크 확장은 1초마다 짧게 사는 nettop 프로세스를 실행하는 대체 샘플러보다 비용이 적습니다.
연결을 차단할 수 있나요?
-
네, 네트워크 확장이 있으면 됩니다. 에이전트의 허용 목록은 경고뿐 아니라 거부도 할 수 있고, 규칙은 앱이나 +
네, 네트워크 확장이 있으면 됩니다. 에이전트의 허용 목록은 경고뿐 아니라 거부도 할 수 있고, 규칙은 앱이나 목적지, URL을 원하는 기간만큼 막을 수 있으며, 가드레일은 모델에 제공되기 전에 에이전트에서 도구를 걷어낼 수 있습니다. 요청하기 전까지 차단되는 것은 없고, 모든 거부에는 빠져나갈 길이 함께 있습니다.
네트워크 확장이 꼭 필요한가요?
@@ -748,7 +755,7 @@

한계

diff --git a/docs/ko/index.html b/docs/ko/index.html index c83c6364..417e87fd 100644 --- a/docs/ko/index.html +++ b/docs/ko/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

앱이 무엇을 하는지 봅니다.

brew install --cask xinbetween/tap/flowlight

-

v0.17.3macOS 15+유니버설GPL-3.0텔레메트리 없음

+

v0.17.7macOS 15+유니버설GPL-3.0텔레메트리 없음

@@ -127,7 +127,7 @@

앱이 무엇을 하는지 봅니다.
-
Flowlight의 AI 활동 지도. 에이전트 카드는 귀속된 도구와 구성된 게이트웨이를 거치며, 관측된 magpie에서 Flowlight로의 경로가 LLM 서비스와 도메인으로 분기됩니다. 리본 너비는 기간 내 트래픽 양을 나타냅니다.

실제 AI 경로를 확인하세요. 에이전트, 귀속된 도구, 구성된 게이트웨이, Flowlight가 관측한 프록시 단계와 정확한 목적지를 표시합니다. 리본 너비는 선택한 기간의 수신량과 송신량 합계입니다. 데모 데이터로 표시했습니다.

@@ -135,7 +135,7 @@

앱이 무엇을 하는지 봅니다.
-
Flowlight의 AI 에이전트 뷰. Claude Code, Cursor, Codex, Python 스크립트가 각자의 AI 제공업체, 다른 곳으로 보낸 트래픽, 그리고 SMTP, SSH, FTP, 대용량 업로드, 원시 IP에 대한 위험 배지와 함께 나열되어 있습니다.

AI 에이전트 뷰: 각 에이전트의 모델 제공업체, 그리고 그 밖에 통신한 모든 곳. 데모 데이터로 표시했습니다.

@@ -179,11 +179,11 @@

기록

다 어디로 갔나? 초에서 연까지, 앱·목적지·IP

알림

무엇이 이상했나? 목적지와 임계값까지 밝히는, 설명되는 앱별 알림.

⌘5
-

규칙

무엇을 거부했고, 실제로 막히고 있나? 모든 규칙과 각 규칙이 막은 것, 그리고 모든 판정의 기록.

⌘8 · Developer ID / ⌘7 · App Store
+

정책

어떤 제어가 있고 실제로 효력이 있나? 모든 정책과 실제 상태, 그리고 하나로 모은 활동 기록.

⌘8 · Developer ID / ⌘7 · App Store

질문

평소 쓰는 말로 묻고 기록에서 답을 받습니다. 실행된 질의는 아래에 전부 표시됩니다.

⌘6
-

소스

데이터가 들어오는지, 이름이 붙은 비율과 활성 기기 채널은 어떤지. 컬렉터, 기기, 포착 범위를 한곳에서 봅니다.

⌘9 · Developer ID / ⌘8 · App Store
+

소스

데이터가 들어오는지, 어떤 기기 채널이 활성화되었는지, Flowlight가 어디까지 파악할 수 있는지. 컬렉터부터 포착 범위까지 한 페이지에서 봅니다.

⌘9 · Developer ID / ⌘8 · App Store

검사

선택 사항. 에이전트가 실제로 무엇을 보냈고, 어떤 도구 호출이 그렇게 했나? 복호화된 요청, 도구 호출, 결과.

⌘7 · Developer ID
@@ -405,16 +405,16 @@

도구 호출과 네트워크 요청을 잇습니다

검사 대상인 터미널에서 에이전트를 실행하면 에이전트와 그 도구의 지원되는 HTTP 트래픽이 복호화되고, 모델이 요청한 도구 호출과 되돌아간 결과가 함께 보입니다.

-
Flowlight의 검사 뷰: 복호화된 Claude Code 요청과 각 요청의 도구 호출 목록, 그리고 curl이 paste.example로 올린 업로드의 상세 내용. 그 업로드를 실행한 Bash 도구 호출과 요청 본문에 담긴 메모가 함께 보입니다
내 메모는 어디로 갔나? curl이 paste.example로 올린 업로드를, 모델이 요청한 Bash 명령까지 거슬러 올라가 보낸 본문과 함께.
-
Claude Code의 도구 탭: Bash, Read와 MCP 도구 두 개가 설명, 그리고 모델이 각각을 몇 번 호출했는지와 함께 나열되어 있습니다
에이전트가 모델에게 내주는 모든 도구. 에이전트는 매 턴마다 도구를 선언하므로 Flowlight는 그것을 전부 나열하고, 모델이 실제로 쓴 것과 한 번도 건드리지 않은 것을 구분해 보여줍니다.
-
Codex의 MCP 서버 탭: 제공업체 경유로 표시된 sentry 서버, 그 URL, 승인은 사용 안 함, 허용된 도구, 그리고 서버가 제공하는 도구들
회선에서는 보이지 않는 것까지. 에이전트가 제공업체에게 MCP 서버로 연결해 달라고 하면 그 트래픽은 내 Mac을 거치지 않습니다. 하지만 요청은 거치므로 서버와 URL, 승인 설정, 제공하는 도구는 그대로 드러납니다.
@@ -460,7 +460,7 @@

감시하지 않아도 되는 감시 도구

-
Flowlight에 표시된 Claude Code 상세: 호출 횟수와 실패 하나가 붙은 도구와 MCP 서버, 허용 목록, 그리고 실패한 git push, 응답과 요청이 함께 있는 paste.example로의 curl 업로드, npm install, GitHub 이슈, 문서 검색, 파일 읽기가 각각 결과와 함께 나열된 도구 호출 탭
에이전트 하나, 도구 호출 전부. Claude Code가 띄운 git과 curl, MCP 서버의 트래픽은 Claude Code의 것으로 집계됩니다. HTTPS 검사를 켜면 도구 호출 탭에서 모델이 무엇을 요청했고 무엇이 돌아왔으며 각 도구가 어떤 요청을 @@ -470,10 +470,10 @@

감시하지 않아도 되는 감시 도구

-
시간별 수신·송신 막대와 앱, 목적지, 프로토콜 도넛 차트가 있는 기록 뷰
기록. 초에서 연까지의 확대·축소, 세 가지 묶음, 앱이 수백 개여도 읽히는 차트.
-
에이전트 유출, 이메일, FTP, 자리 비움, 트래픽 급증 알림이 나열된 알림 뷰
알림. 하나하나가 앱과 목적지, 그리고 기준을 넘긴 수치를 밝힙니다.
@@ -589,7 +589,7 @@

내 Mac에서 무엇이 나가는지 아세요.

diff --git a/docs/ko/privacy/index.html b/docs/ko/privacy/index.html index 6ea64c96..2116fba9 100644 --- a/docs/ko/privacy/index.html +++ b/docs/ko/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -126,7 +126,7 @@

앱이 내는 네트워크 요청

- + @@ -190,7 +190,7 @@

문의

diff --git a/docs/ko/threat-model/index.html b/docs/ko/threat-model/index.html index e6efc75c..62b37bcb 100644 --- a/docs/ko/threat-model/index.html +++ b/docs/ko/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -221,7 +221,7 @@

취약점 신고하기

diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 18efdd43..2ce778c1 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -62,7 +62,7 @@ Documentation Using Flowlight - Everything from the first launch to tuning the agent rules. Flowlight 0.17.3, macOS 15 or later. + Everything from the first launch to tuning the agent rules. Flowlight 0.17.7, macOS 15 or later. On this page @@ -79,7 +79,7 @@ Documentation AskHTTPS inspectionMock responses Control - RulesAllowlists + PoliciesAllowlists Refusing connectionsGuardrails Sources @@ -163,13 +163,13 @@ open build/Build/Products/Release/Flowlight.app Inspect⌘7 · Developer IDOptional: decrypted requests from the agents you route through Flowlight, and the tool calls behind them. Search reaches into request and response bodies. - Rules⌘8 · Developer ID -⌘7 · App StoreEvery allowlist and refusal you have set, what each one covers, and what it has actually stopped. + Policies⌘8 · Developer ID +⌘7 · App StoreEvery configured policy, whether it is actually effective, and a unified read-only activity trail. Sources⌘9 · Developer ID -⌘8 · App StoreCollector health and recovery, hostname coverage, and opt-in camera, Bluetooth, USB and storage activity in three tabs. +⌘8 · App StoreCollector health and recovery, opt-in device channels, and coverage as one scrollable operational page. - The shortcuts follow the visible sidebar order. Developer ID builds include Inspect at ⌘7; App Store builds omit Inspect, so Rules and Sources move to ⌘7 and ⌘8. + The shortcuts follow the visible sidebar order. Developer ID builds include Inspect at ⌘7; App Store builds omit Inspect, so Policies and Sources move to ⌘7 and ⌘8. Live, the screen Flowlight opens on. In History, choose 15 min, 1h, 6h, 1d, 1w, 1 mo, 6 mo, 1y, or All. Hover-only bars open a cursor-following, scrollable card with the bucket totals and contributing apps; they never change the selected range. The navigator below the chart starts with Nearby context and can switch to All history: drag the brush to pan the selected window, or drag either edge to resize it. Double-click any breakdown row to narrow History to it. @@ -224,7 +224,7 @@ open build/Build/Products/Release/Flowlight.app never been reached, and one only ever observed was never written down anywhere you could have read it. Each domain offers two refusals: for this agent, which covers the agent and the tools it starts, or - everywhere, which covers every app on the Mac. Both write an ordinary rule, so the violations feed reports + everywhere, which covers every app on the Mac. Both write an ordinary rule, so Activity reports them and the rule editor explains them — and because they are ordinary rules, the simulation in the editor will tell you what one would have refused before you switch it on. @@ -464,18 +464,23 @@ open build/Build/Products/Release/Flowlight.app connected to: a mock changes the answer, not whether the connection can be made, so "the API returns 500" works while "the API is entirely unreachable" does not. - Rules + Policies - An allowlist is one agent's idea of where it may go. A rule is the general version: it names a + Policies is one inventory for every control Flowlight can apply: ordinary allow/block rules, agent + allowlists, managed packs, and—where HTTPS inspection is available—guardrails, mocks, request rewrites, and response + transforms. Each row distinguishes configured state from actual effect: enforcing, reporting, watching, waiting, + scheduled, paused, spent, applying, review required, unavailable, incompatible, or failed. Selecting a row opens the + contextual editor that owns that policy; Policies does not replace or blur those separate consent and persistence models. + + An allowlist is one agent's idea of where it may go. An ordinary rule is the general version: it names a subject — an app, an agent, a destination, a URL, or a pairing of two of those — and says block or allow. Right-click any row in Live, History, AI Agents or Inspect to write one about what you're looking at; - every one of them lands in the same editor on the Rules screen, because a rule that took effect out of sight - would be a rule you could never find again. + every one remains findable in Policies and opens in its authoritative editor. Templates. Twenty-two of them, in five groups — stopping an agent reaching package registries or other model providers, closing the common ways data leaves (paste sites, webhook catchers, anonymous file drops, tunnels, DNS over HTTPS), product analytics and crash reporting, working-hours policies, and one-off "block this for an hour while I work out - what it is". Browse them from the Rules toolbar, or right-click a row anywhere in Live, History, AI Agents or Inspect and + what it is". Browse them from the Policies toolbar, or right-click a row anywhere in Live, History, AI Agents or Inspect and apply one to what you're looking at. Each template says what it will do before it does it. Anything that writes more than one rule opens a card listing every @@ -502,8 +507,9 @@ open build/Build/Products/Release/Flowlight.app URL and answer with a status the agent can read instead of a socket that died without saying why. The nettop sampler cannot refuse anything at all. A rule the current setup can't carry out says so in the list rather than sitting there looking enforced. - The second tab is the violations feed: every connection a rule decided, with the app, where it was headed, which - rule decided it and whether that was a refusal or an exception letting it through. + The second view is read-only Activity: ordinary rule decisions, allowlist violations, managed-pack events, + and distinct inspection-policy effects in one chronology. It adapts the existing authoritative records rather than rewriting + history into one generic event model; Store builds omit inspection-dependent policy kinds and their retained activity. What it would have done. A blocking rule is easy to write and frightening to switch on, because the first thing you normally learn about one is what it broke — and you learn it later, from something failing somewhere else. The rule @@ -526,7 +532,7 @@ open build/Build/Products/Release/Flowlight.app Managed ad and tracker packs - Rules › Packs contains three official lists built into Flowlight: Advertising covers known + Policies › Managed packs contains three official lists built into Flowlight: Advertising covers known advertising delivery and auction hosts; Cross-site tracking covers common analytics, session replay and cross-site measurement hosts; and Privacy and telemetry covers crash reporting, install attribution, and audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default. @@ -631,9 +637,11 @@ open build/Build/Products/Release/Flowlight.app This is not a sandbox. A refused tool stops being offered and stops being answered. An agent that still has a shell can do by hand what the tool would have done. Flowlight will record that; it does not pretend to prevent it. - Sources: collector, coverage and devices + Sources: collector, devices and coverage - Sources keeps operational setup in one place. Its health summary shows whether traffic is arriving, how much is named, and which optional device channels are active; any unfinished collector step links directly to Sources › Collector. + Sources is one scrollable operational page: Collector first, then Devices and Coverage as clearly headed + sections. Status notices, Ask answers, restored windows, and screenshot links scroll to and briefly focus the relevant section + without hiding the others. Flowlight records traffic one of two ways, chosen under Sources › Collector. They differ in what they can miss, which is worth understanding before you rely on either. @@ -920,7 +928,7 @@ Understand your AI agents. brew install --cask xinbetween/tap/flowlightCopy - v0.17.3macOS 15+UniversalGPL-3.0No telemetry + v0.17.7macOS 15+UniversalGPL-3.0No telemetry connectionslive @@ -1003,8 +1011,8 @@ Where did it all go? From 15 minutes through all history, grouped by app, destin What looked wrong? Explainable, per-app alerts with the destination and the threshold. ⌘5 - Rules -What have I refused, and is it happening? Every rule, what each has blocked, and a feed of every decision. + Policies +What controls exist, and are they effective? Every policy, its real state, and a unified activity trail. ⌘8 · Developer ID / ⌘7 · App Store Ask @@ -1012,7 +1020,7 @@ Questions in plain language, answered from the history — with every query it r ⌘6 Sources -Is data arriving, how much is named, and which device channels are active? Collector, Devices and Coverage in one place. +Is data arriving, which device channels are active, and what can Flowlight account for? One page: Collector, Devices, then Coverage. ⌘9 · Developer ID / ⌘8 · App Store Inspect @@ -1461,6 +1469,8 @@ Plugins A JavaScript plugin defines a synchronous evaluate(context) function. Flowlight passes context.manifest and a sanitized context.exchange containing only the metadata families declared in permissions. Scripts run out of process with an empty environment, a 128 KB script limit and a short timeout; failures produce no findings. + context.manifest.configuration contains that plugin's local string settings. context.manifest.providerProfiles is a separate array of enabled local profiles assigned to the plugin with the same guardrail provider; each entry contains only id, provider, optional endpoint, and non-secret configuration. Flowlight never gives a plugin credential values or Keychain references, and a profile does not initiate a provider request. + A finding is a bounded record attached to one inspected exchange. Evidence should name facts, not leak content: method, host, redacted path shape, byte counts, header names, model provider, tool names, MCP server names, approval mode and policy labels. Do not store header values, raw bodies, credentials, full tool inputs, prompts, completions or unredacted query strings. When a manifest declares the suggestAction capability and permission, a finding can also include suggestedActions. Flowlight stores those as inert suggestions; Inspect opens the matching editor and saves only when the user reviews and clicks Save. @@ -1519,7 +1529,7 @@ Plugins Template package - The starter package lives at templates/inspection-plugin. It includes a manifest, plugin.js, sample findings and a validator for ordinary traffic and LLM/MCP guardrail plugins. Import the manifest JSON from Settings › Plugins and Flowlight will show the review sheet before installation. + The starter package lives at templates/inspection-plugin. It includes a manifest, plugin.js, sample findings and a validator for ordinary traffic and LLM/MCP guardrail plugins. In the Developer edition, open Inspect › Plugins & Providers and import the manifest JSON; Flowlight shows the review sheet before installation. cp -R templates/inspection-plugin my-flowlight-plugin cd my-flowlight-plugin @@ -1614,7 +1624,7 @@ Privacy Update downloadgithub.comThe disk image and its checksum fileOnly when you click Download & Open - Official rule-pack updateflowlight.xinbetween.comA standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/ user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifierAfter startup when due and then at most daily while healthy. On by default; off switch and manual Check Now in Rules › Packs + Official rule-pack updateflowlight.xinbetween.comA standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/ user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifierAfter startup when due and then at most daily while healthy. On by default; off switch and manual Check Now under Policies › Managed packs Reverse DNSYour configured DNS resolverA lookup of an IP address's registered name, when no hostname was seenAs needed, in the sampler capture mode, which is the default until the Network Extension is running. No off switch: without it the sampler cannot name most connections. The Network Extension names them from the traffic itself and does not do this @@ -1664,8 +1674,31 @@ Releases Downloads, checksums and full notes for each version are on GitHub Releases. + 0.17.7 +November 5, 2026Latest + + Account for every AI agent. Agents now combines installed apps, configured workspaces, known launchers and observed traffic into one inventory, with explainable health reasons for evidence, routing, inspection, policy coverage and unresolved alerts instead of an opaque score. + + Take alerts from new to resolved. Alerts now supports new, acknowledged, investigating, resolved and dismissed states; dispositions, notes and bulk actions stay separate from policy Allow actions, while the sidebar badge counts only new alerts. + + Manage inspection extensions where they work. The Developer edition moves plugin packages into Inspect › Plugins & Providers and adds versioned local provider profiles with compatible plugin assignment, endpoint validation and Keychain references without persisting raw credentials. + + Inspect Live traffic without relying on color or a pointer. The Live chart adds keyboard traversal, VoiceOver details, strict plot hit testing, a solid/circle download series and dashed/diamond upload series, with an executable light/dark palette gate. + + Keep redesigned paths within measured limits. Fail-capable numeric budgets cover startup, a 600-edge graph, a 10,001-row History query, a 10,000-alert query, a 1,000-agent inventory and 30,000 built-in plugin evaluations, with complete diagnostics on failure. + + Carry every completed change forward safely. The unpublished 0.17.4, 0.17.5 and 0.17.6 work ships here: Policies preserves each policy type's authoritative editor and evidence, Sources is one Collector–Devices–Coverage document, History clears pointer details without inventing a keyboard selection, and release review receipts remain valid when GitHub hides a trusted reviewer's private organization membership from the workflow token. + + Reuse CI only when the evidence is exact. Pull-request app jobs may reuse a successful canonical run only when its full Git tree, app fingerprint, check conclusions, receipt schema and immutable artifact identity match; every invalid or forked case executes the current jobs. + + Deploy only the site CI verified. Pages consumes the exact manifested site artifact from successful canonical main CI, with no source checkout or rebuild, while weekly full CodeQL remains in place. + + Keep untrusted pull-request refs out of checkouts. Merge compatibility uses GitHub's trusted pull-request event merge commit rather than constructing a checkout ref from pull-request input, resolving code-scanning alert 4 without granting privileged workflow access. + + Sign each release once. The immutable tag run builds, signs, notarizes, Gatekeeper-verifies, attests and retains one artifact; credential-free publication can only verify and publish those exact prepared bytes, while accepted Apple tickets survive a bounded multi-minute CloudKit delivery outage without resubmission. + 0.17.3 -November 4, 2026Latest +November 4, 2026 Keep every input in one operational home. Capture, Devices and Coverage are now Collector, Devices and Coverage tabs inside Sources, with a shared health summary and a step-by-step recovery card when traffic is not arriving. diff --git a/docs/llms.txt b/docs/llms.txt index 96c8b063..d05e8fd0 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -1,6 +1,6 @@ # Flowlight -> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.17.3. +> Free, open-source (GPL-3.0) application-aware network monitor for macOS, with focused visibility into AI agents. It attributes observed TCP and UDP activity to the application that made it and records the destination, protocol and byte counts, keeping local history from second to year. Recognized AI agents are listed by name along with the tools and MCP servers they start, under per-agent allowlists. It can also refuse, once asked: a rule blocks an application, a destination or a URL for as long as you specify, and a guardrail withholds a tool from an agent before its model is offered it. Runs on macOS 15 or later; capture is by a sampler or a Network Extension. Current version: 0.17.7. - [Download Flowlight.dmg](https://github.com/xinbetween/flowlight/releases/latest/download/Flowlight.dmg) - [Source code](https://github.com/xinbetween/flowlight) diff --git a/docs/plugins/index.html b/docs/plugins/index.html index 7ef95b0c..63383834 100644 --- a/docs/plugins/index.html +++ b/docs/plugins/index.html @@ -28,7 +28,7 @@ - + @@ -113,6 +113,7 @@

Provenance and package review

Findings

A JavaScript plugin defines a synchronous evaluate(context) function. Flowlight passes context.manifest and a sanitized context.exchange containing only the metadata families declared in permissions. Scripts run out of process with an empty environment, a 128 KB script limit and a short timeout; failures produce no findings.

+

context.manifest.configuration contains that plugin's local string settings. context.manifest.providerProfiles is a separate array of enabled local profiles assigned to the plugin with the same guardrail provider; each entry contains only id, provider, optional endpoint, and non-secret configuration. Flowlight never gives a plugin credential values or Keychain references, and a profile does not initiate a provider request.

A finding is a bounded record attached to one inspected exchange. Evidence should name facts, not leak content: method, host, redacted path shape, byte counts, header names, model provider, tool names, MCP server names, approval mode and policy labels. Do not store header values, raw bodies, credentials, full tool inputs, prompts, completions or unredacted query strings.

When a manifest declares the suggestAction capability and permission, a finding can also include suggestedActions. Flowlight stores those as inert suggestions; Inspect opens the matching editor and saves only when the user reviews and clicks Save.

{
@@ -161,7 +162,7 @@ 

Supported guardrail providers

요청보내는 곳담기는 내용시점
업데이트 확인api.github.com최신 릴리스를 묻는 일반적인 HTTPS 요청. GitHub는 IP 주소와 Flowlight/<version> 사용자 에이전트를 보게 됩니다0.1.2부터, 실행 시와 하루에 한 번. 끄는 스위치는 설정 › 일반
업데이트 다운로드github.com디스크 이미지와 체크섬 파일다운로드 후 열기를 클릭할 때만
공식 규칙 팩 업데이트flowlight.xinbetween.com고정된 공식 피드로 보내는 표준 HTTPS 요청입니다. 서버에는 사용자의 IP 주소, Flowlight/<version> 사용자 에이전트 및 조건부 캐시 유효성 검사기가 표시되며, 트래픽, 팩 설정 또는 기기 식별자는 전송되지 않습니다시작 후 확인 시점이 되면 실행되고, 이후 정상 작동 중에는 하루에 최대 한 번 실행됩니다. 기본적으로 켜져 있으며, 규칙 › 팩에서 끄거나 지금 확인을 수동으로 실행할 수 있습니다
공식 규칙 팩 업데이트flowlight.xinbetween.com고정된 공식 피드로 보내는 표준 HTTPS 요청입니다. 서버에는 사용자의 IP 주소, Flowlight/<version> 사용자 에이전트 및 조건부 캐시 유효성 검사기가 표시되며, 트래픽, 팩 설정 또는 기기 식별자는 전송되지 않습니다시작 후 확인 시점이 되면 실행되고, 이후 정상 작동 중에는 하루에 최대 한 번 실행됩니다. 기본적으로 켜져 있으며, 정책 › 관리형 팩에서 끄거나 지금 확인을 수동으로 실행할 수 있습니다
역방향 DNS설정된 DNS 리졸버호스트 이름이 관측되지 않았을 때, IP 주소에 등록된 이름 조회필요할 때, 샘플러 캡처 모드에서. 네트워크 확장이 돌기 시작할 때까지는 이것이 기본값입니다. 끄는 스위치는 없습니다. 이것이 없으면 샘플러는 대부분의 연결에 이름을 붙일 수 없습니다. 네트워크 확장은 트래픽 자체에서 이름을 붙이므로 이 조회를 하지 않습니다
네트워크 소유자 조회Team Cymru, DNS로알려진 호스트 이름이 없는 공용 IP 주소. 사설 주소와 예약 주소는 절대 Mac을 벗어나지 않습니다기본값 켬. 끄는 스위치는 캡처 › 네트워크 소유자 확인
호스팅 모델로 질문선택한 제공업체: Anthropic, OpenAI, Google Gemini 또는 호환 엔드포인트내 질문과, 모델이 고른 Flowlight 읽기 전용 질의가 돌려준 결과. 트래픽 데이터베이스 자체는 업로드되지 않습니다호스팅 제공업체를 고르고 질문을 보낼 때만. 질문 화면에 전송된 요청이 표시됩니다

Template package

-

The starter package lives at templates/inspection-plugin. It includes a manifest, plugin.js, sample findings and a validator for ordinary traffic and LLM/MCP guardrail plugins. Import the manifest JSON from Settings › Plugins and Flowlight will show the review sheet before installation.

+

The starter package lives at templates/inspection-plugin. It includes a manifest, plugin.js, sample findings and a validator for ordinary traffic and LLM/MCP guardrail plugins. In the Developer edition, open Inspect › Plugins & Providers and import the manifest JSON; Flowlight shows the review sheet before installation.

cp -R templates/inspection-plugin my-flowlight-plugin
 cd my-flowlight-plugin
 node validate.js
@@ -212,7 +213,7 @@

Review checklist

diff --git a/docs/privacy/index.html b/docs/privacy/index.html index e0d1be2d..2bb7da17 100644 --- a/docs/privacy/index.html +++ b/docs/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -131,7 +131,7 @@

Network requests the app makes

RequestSent toWhat it containsWhen Update checkapi.github.comA standard HTTPS request for the latest release; GitHub sees your IP address and the Flowlight/<version> user agentAfter a clean quit and relaunch, and daily. Off switch in Settings › General Update downloadgithub.comThe disk image and its checksum fileOnly when you click Download & Open - Official rule-pack updateflowlight.xinbetween.comA standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/<version> user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifierAfter startup when due and then at most daily while healthy. On by default; off switch and manual Check Now in Rules › Packs + Official rule-pack updateflowlight.xinbetween.comA standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/<version> user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifierAfter startup when due and then at most daily while healthy. On by default; off switch and manual Check Now under Policies › Managed packs Reverse DNSYour configured DNS resolverA lookup of an IP address's registered name, when no hostname was seenAs needed, in the sampler capture mode, which is the default until the Network Extension is running. No off switch: without it the sampler cannot name most connections. The Network Extension names them from the traffic itself and does not do this Network owner lookupTeam Cymru, over DNSPublic IP addresses with no known hostname. Private and reserved addresses never leave your MacOn by default. Off switch in Sources › Collector › Identify network owners Ask with a hosted modelThe provider you select: Anthropic, OpenAI, Google Gemini or a compatible endpointYour question and the results returned by the read-only Flowlight queries selected by the model. The traffic database itself is not uploadedOnly when you select a hosted provider and submit a question. The Ask screen shows the transmitted requests @@ -196,7 +196,7 @@

Contact

diff --git a/docs/pt-PT/about/index.html b/docs/pt-PT/about/index.html index 014a54a7..695e3099 100644 --- a/docs/pt-PT/about/index.html +++ b/docs/pt-PT/about/index.html @@ -48,7 +48,7 @@ - + @@ -152,7 +152,7 @@

Agradecimentos

diff --git a/docs/pt-PT/docs/index.html b/docs/pt-PT/docs/index.html index 2cf81572..1ac50b7b 100644 --- a/docs/pt-PT/docs/index.html +++ b/docs/pt-PT/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

Documentação

Usar o Flowlight

-

Tudo, da primeira abertura ao ajuste das regras dos agentes. Flowlight 0.17.3, macOS 15 ou posterior.

+

Tudo, da primeira abertura ao ajuste das regras dos agentes. Flowlight 0.17.7, macOS 15 ou posterior.

@@ -170,11 +170,11 @@

As vistas

Alertas⌘5Tudo o que pareceu invulgar, com a app, o destino e o número que disparou a regra. Perguntar⌘6Perguntas sobre o seu próprio tráfego em linguagem simples, respondidas a partir do histórico registado por um modelo local ou alojado. Inspecionar⌘7 · Developer IDOpcional: pedidos desencriptados dos agentes que encaminha pelo Flowlight e as chamadas de ferramentas por trás deles. A pesquisa entra nos corpos dos pedidos e das respostas. - Regras⌘8 · Developer ID
⌘7 · App StoreTodas as listas de permissões e recusas que definiu, o que cada uma abrange e o que de facto travou. - Origens⌘9 · Developer ID
⌘8 · App StoreEstado do coletor, canais de dispositivos e cobertura de nomes em três separadores. + Políticas⌘8 · Developer ID
⌘7 · App StoreTodas as políticas configuradas, se estão realmente em vigor e um histórico unificado de atividade só de leitura. + Origens⌘9 · Developer ID
⌘8 · App StoreEstado e recuperação do Coletor, canais opcionais de dispositivos e cobertura numa única página operacional deslocável. -

Os atalhos seguem a ordem visível da barra lateral. As versões Developer ID incluem Inspecionar em ⌘7; as versões App Store omitem-no, por isso Regras e Origens passam para ⌘7 e ⌘8.

-
Os atalhos seguem a ordem visível da barra lateral. As versões Developer ID incluem Inspecionar em ⌘7; as versões App Store omitem-no, por isso Políticas e Origens passam para ⌘7 e ⌘8.

+
A vista Em direto: uma lista de aplicações com as suas taxas de envio e receção atuais e o destino mais ativo.
Em direto, a vista com que o Flowlight abre.

Em Histórico, passe o rato sobre uma barra para abrir um cartão deslocável que segue o ponteiro e mostra as apps que contribuíram; as barras não ampliam ao clicar. Faça duplo clique numa linha para restringir o histórico a essa linha.

@@ -406,16 +406,20 @@

Respostas simuladas

continua a ser feita: uma simulação muda a resposta, não a possibilidade de estabelecer a ligação, por isso «a API devolve 500» funciona e «a API está completamente inacessível» não.

-

Regras

-

Uma lista de permissões é a ideia de um agente sobre onde pode ir. Uma regra é a versão geral: nomeia um +

Políticas

+

Políticas reúne num só inventário todos os controlos que o Flowlight pode aplicar: regras comuns de permissão ou bloqueio, listas de + permissões dos agentes, pacotes geridos e, quando a inspeção HTTPS está disponível, proteções, simulações, reescritas de pedidos e transformações de + respostas. Cada linha distingue o estado configurado do efeito real: em aplicação, apenas a comunicar, a vigiar, à espera, agendada, em pausa, + esgotada, a ser aplicada, a precisar de revisão, indisponível, incompatível ou com falha. Selecionar uma linha abre o editor contextual responsável + por essa política; Políticas não substitui nem confunde os diferentes modelos de consentimento e persistência.

+

Uma lista de permissões é a ideia de um agente sobre onde pode ir. Uma regra comum é a versão geral: nomeia um sujeito — uma app, um agente, um destino, um URL, ou um par formado por dois deles — e diz bloquear ou permitir. Clique com o botão direito em qualquer linha de Em direto, Histórico, Agentes de IA ou Inspecionar para escrever uma sobre o que está a ver; - todas elas vão dar ao mesmo editor no ecrã Regras, porque uma regra que entrasse em vigor fora da vista - seria uma regra que nunca mais conseguiria encontrar.

+ cada uma continua disponível em Políticas e abre no seu editor responsável.

Modelos. Vinte e dois, em cinco grupos — impedir um agente de chegar a registos de pacotes ou a outros fornecedores de modelos, fechar as vias habituais por onde os dados saem (sites de paste, apanhadores de webhooks, depósitos anónimos de ficheiros, túneis, DNS over HTTPS), analítica de produto e relatórios de falhas, políticas de horário de trabalho, e o pontual «bloquear isto por uma hora enquanto descubro - o que é». Consulte-os na barra de ferramentas de Regras, ou clique com o botão direito numa linha em Em direto, Histórico, Agentes de IA ou Inspecionar e + o que é». Consulte-os na barra de ferramentas de Políticas, ou clique com o botão direito numa linha em Em direto, Histórico, Agentes de IA ou Inspecionar e aplique um ao que está a ver.

Cada modelo diz o que vai fazer antes de o fazer. Tudo o que escreva mais do que uma regra abre um cartão com todos os anfitriões e horários; tudo o que seja permanente abre o editor pré-preenchido; e só as regras que se desfazem sozinhas — uma @@ -436,8 +440,9 @@

Regras

endereço e a porta, nada sobre caminhos, e só no modo de extensão. A inspeção HTTPS recusa um pedido, por isso consegue corresponder a um URL e responder com um estado que o agente pode ler, em vez de um socket que morreu sem dizer porquê. O amostrador nettop não consegue recusar nada. Uma regra que a configuração atual não consegue aplicar di-lo na lista, em vez de ficar ali com ar de estar em vigor.

-

O segundo separador é o fluxo de violações: todas as ligações que uma regra decidiu, com a app, para onde ia, que - regra a decidiu e se isso foi uma recusa ou uma exceção a deixá-la passar.

+

A segunda vista é a Atividade, só de leitura: reúne numa única cronologia as decisões das regras comuns, as violações das listas de permissões, + os eventos dos pacotes geridos e os efeitos distintos das políticas de inspeção. Baseia-se nos registos originais fidedignos em vez de reescrever o + histórico num único modelo genérico de eventos; as versões Store omitem os tipos de política dependentes da inspeção e a respetiva atividade conservada.

O que teria feito. Uma regra de bloqueio é fácil de escrever e assustadora de ligar, porque a primeira coisa que normalmente se aprende sobre uma delas é o que estragou — e aprende-se mais tarde, a partir de algo que falha noutro sítio. @@ -458,7 +463,7 @@

Regras

testada contra duas horas de histórico.

Pacotes geridos de publicidade e rastreadores

-

Regras › Pacotes contém três listas oficiais de nomes de anfitrião: Publicidade, Rastreio entre sites e Privacidade e telemetria. Estão visíveis desde o início e todas desativadas por predefinição. O bloqueio requer a Extensão de rede; o amostrador nettop apenas pode observar.

+

Políticas › Pacotes geridos contém três listas oficiais de nomes de anfitrião: Publicidade, Rastreio entre sites e Privacidade e telemetria. Estão visíveis desde o início e todas desativadas por predefinição. O bloqueio requer a Extensão de rede; o amostrador nettop apenas pode observar.

Apenas atualizações oficiais e assinadas. O Flowlight consulta uma única fonte HTTPS fixa para procurar catálogos mais recentes assinados pelo editor. As verificações automáticas estão ativas por predefinição e podem ser desativadas aqui. Uma assinatura autentica o conteúdo, mas não o ativa: um novo pacote permanece desativado e, se os destinos forem alterados, um pacote ativo deixa de ser aplicado até que reveja a nova versão exata e prima Guardar. Durante uma interrupção, o conteúdo aceite pode permanecer disponível como a última versão válida conhecida, visivelmente assinalada como desatualizada. Estes são bloqueios de rede ao nível do nome de anfitrião: não podem remover espaços vazios no DOM ou no esquema, sobreposições do navegador, janelas pop-up geradas por scripts ou janelas de aplicações nativas.

Quando a inspeção HTTPS encaminha uma app pelo proxy do Flowlight, este decide as regras de destino comuns e os pacotes geridos ativos com base na identidade original da app antes de abrir qualquer ligação ao servidor de origem. Nos túneis CONNECT com certificado fixado ou que nunca são decifrados, isto funciona pelo nome de anfitrião; as listas de permissões dos agentes continuam limitadas ao caminho direto pela Extensão de rede. Se o proprietário ou o nome de anfitrião for desconhecido, a ligação é permitida.

@@ -507,7 +512,10 @@

Proteções dos agentes: bloquear uma ferramenta, um servido

Isto não é uma sandbox. Uma ferramenta recusada deixa de ser oferecida e deixa de ser respondida. Um agente que ainda tenha uma shell pode fazer à mão o que a ferramenta teria feito. O Flowlight vai registá-lo; não finge impedi-lo.

-

Origens: coletor e motores

+

Origens: coletor, dispositivos e cobertura

+

Origens é uma única página operacional deslocável: primeiro o Coletor, depois Dispositivos e Cobertura como secções claramente + identificadas. Os avisos de estado, as respostas de Perguntar, as janelas restauradas e as ligações usadas nas capturas de ecrã deslocam-se até à secção relevante + e realçam-na por instantes sem ocultar as restantes.

O Flowlight regista o tráfego de uma de duas formas, escolhida em Origens › Coletor. Diferem no que podem deixar escapar, e vale a pena entender isso antes de confiar numa delas.

@@ -699,7 +707,7 @@

Perguntas e respostas

Com a janela fechada, o Flowlight quase não faz nada: mantém as taxas na barra de menus e escreve uma linha por segundo. A extensão de rede custa menos do que o amostrador de reserva, que inicia um processo nettop de vida curta a cada segundo.
Consegue bloquear ligações?
-
Sim, com a extensão de rede. A lista de permissões de um agente pode recusar e não só avisar, uma regra pode bloquear uma app, +
Sim, com a extensão de rede. A lista de permissões de um agente pode recusar e não só avisar, uma regra pode bloquear uma app, um destino ou um URL pelo tempo que quiser, e uma proteção pode retirar uma ferramenta a um agente antes de ser oferecida ao seu modelo. Nada bloqueia até o pedir, e cada recusa traz consigo a sua saída.
Preciso da extensão de rede?
@@ -754,7 +762,7 @@

Limitações

diff --git a/docs/pt-PT/index.html b/docs/pt-PT/index.html index b220806e..b6d2c91d 100644 --- a/docs/pt-PT/index.html +++ b/docs/pt-PT/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

Veja a atividade de rede das suas apps.

brew install --cask xinbetween/tap/flowlight

-

v0.17.3macOS 15+UniversalGPL-3.0Sem telemetria

+

v0.17.7macOS 15+UniversalGPL-3.0Sem telemetria

@@ -127,7 +127,7 @@

Veja a atividade de rede das suas apps.
-
O mapa de atividade de IA do Flowlight. Os cartões de agentes passam por ferramentas atribuídas e gateways configurados; a rota observada do magpie para o Flowlight ramifica-se depois para serviços LLM e domínios. A largura das faixas mostra o tráfego do período.

Veja a rota de IA real: agentes, ferramentas atribuídas, gateways configurados, a etapa de proxy observada pelo Flowlight e destinos exatos. A largura mostra o total recebido e enviado no período selecionado. Com dados de demonstração.

@@ -135,7 +135,7 @@

Veja a atividade de rede das suas apps.
-
A vista Agentes de IA do Flowlight. O Claude Code, o Cursor, o Codex e um script de Python aparecem com os respetivos fornecedores de IA, o tráfego enviado para outros sítios e indicadores de risco para SMTP, SSH, FTP, um envio grande e um IP direto.

A vista Agentes de IA: o fornecedor do modelo de cada agente e tudo o mais com que falou. Mostrada com dados de demonstração.

@@ -179,11 +179,11 @@

Histórico

Para onde foi tudo? Do segundo ao ano, agrupado por app, d

Alertas

O que pareceu errado? Alertas por app, explicáveis, com o destino e o limiar.

⌘5
-

Regras

O que recusei e está a acontecer? Todas as regras, o que cada uma bloqueou e um fluxo de todas as decisões.

⌘8 · Developer ID / ⌘7 · App Store
+

Políticas

Que controlos existem e estão em vigor? Todas as políticas, o seu estado real e um histórico unificado de atividade.

⌘8 · Developer ID / ⌘7 · App Store

Perguntar

Perguntas em linguagem comum, respondidas a partir do histórico — com todas as consultas que fez listadas abaixo.

⌘6
-

Origens

Os dados estão a chegar, quanto está identificado e que canais de dispositivos estão ativos? Coletor, Dispositivos e Cobertura num só lugar.

⌘9 · Developer ID / ⌘8 · App Store
+

Origens

Os dados estão a chegar, que canais de dispositivos estão ativos e o que consegue o Flowlight contabilizar? Uma única página, do Coletor à Cobertura.

⌘9 · Developer ID / ⌘8 · App Store

Inspecionar

Opcional. O que é que o agente enviou de facto, e que chamada de ferramenta o fez? Pedidos desencriptados, chamadas de ferramentas, resultados.

⌘7 · Developer ID
@@ -405,16 +405,16 @@

Relacione as chamadas de ferramentas suportadas com os pedidos de rede

seu Mac. Inicie um agente a partir de um Terminal inspecionado e o tráfego HTTP suportado do agente e das suas ferramentas é desencriptado, com as chamadas de ferramentas que o modelo pediu e os resultados que voltaram.

-
A vista Inspecionar do Flowlight: uma lista de pedidos desencriptados do Claude Code com as respetivas chamadas de ferramentas e o detalhe do envio do curl para paste.example, feito pela chamada da ferramenta Bash que o executou, com as notas enviadas no corpo do pedido
Para onde foram as minhas notas? O envio do curl para paste.example, seguido até ao comando Bash que o modelo pediu, com o corpo que enviou.
-
O separador Ferramentas do Claude Code: Bash, Read e duas ferramentas MCP com descrições e a frequência com que o modelo chamou cada uma
Todas as ferramentas que um agente oferece ao modelo. Os agentes declaram as suas ferramentas em cada turno, por isso o Flowlight lista-as todas, com as que o modelo usou de facto e as que nunca tocou.
-
O separador Servidores MCP do Codex: um servidor sentry marcado como via fornecedor, o seu URL, a aprovação definida como nunca, as ferramentas permitidas e as ferramentas que oferece
Incluindo as que não se veem na rede. Quando um agente pede ao fornecedor que se ligue a um servidor MCP, esse tráfego nunca passa pelo seu Mac. O pedido passa, por isso o servidor, o seu URL, a sua definição de aprovação e as suas ferramentas aparecem mesmo assim.
@@ -460,7 +460,7 @@

Um monitor que não tem de monitorizar

-
O detalhe do Claude Code no Flowlight: ferramentas e servidores MCP com contagens de chamadas e uma falha, uma lista de permissões e o separador Chamadas de ferramentas com um git push falhado, um envio do curl para paste.example com a resposta e o pedido, um npm install, um problema no GitHub, uma pesquisa na documentação e uma leitura de ficheiro, cada um com o seu resultado
Um agente, todas as chamadas de ferramentas. O tráfego do git, do curl e dos servidores MCP que o Claude Code iniciou conta como tráfego do Claude Code. Com a inspeção HTTPS ativada, o separador Chamadas de ferramentas mostra o que o modelo pediu, o que voltou e os pedidos que cada ferramenta fez: @@ -470,10 +470,10 @@

Um monitor que não tem de monitorizar

-
A vista Histórico com barras horárias de dados recebidos e enviados e gráficos circulares de apps, destinos e protocolos
Histórico. Zoom do segundo ao ano, três agrupamentos, gráficos que continuam legíveis com centenas de apps.
-
A vista Alertas a listar alertas de exfiltração por um agente, de e-mail, de FTP, de atividade durante a ausência e de picos de tráfego
Alertas. Cada um nomeia a app, o destino e o número que o disparou.
@@ -589,7 +589,7 @@

Saiba o que sai do seu Mac.

diff --git a/docs/pt-PT/privacy/index.html b/docs/pt-PT/privacy/index.html index 1ff8d6db..e935e805 100644 --- a/docs/pt-PT/privacy/index.html +++ b/docs/pt-PT/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -125,7 +125,7 @@

Pedidos de rede que a app faz

- + @@ -189,7 +189,7 @@

Contacto

diff --git a/docs/pt-PT/threat-model/index.html b/docs/pt-PT/threat-model/index.html index fca537eb..2bf0ad09 100644 --- a/docs/pt-PT/threat-model/index.html +++ b/docs/pt-PT/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -222,7 +222,7 @@

Comunicar uma vulnerabilidade

diff --git a/docs/releases/index.html b/docs/releases/index.html index 56203ee2..e31686c7 100644 --- a/docs/releases/index.html +++ b/docs/releases/index.html @@ -28,7 +28,7 @@ - + @@ -61,7 +61,23 @@

What's new

-

0.17.3

Latest
+

0.17.7

Latest
+
    +
  • Account for every AI agent. Agents now combines installed apps, configured workspaces, known launchers and observed traffic into one inventory, with explainable health reasons for evidence, routing, inspection, policy coverage and unresolved alerts instead of an opaque score.
  • +
  • Take alerts from new to resolved. Alerts now supports new, acknowledged, investigating, resolved and dismissed states; dispositions, notes and bulk actions stay separate from policy Allow actions, while the sidebar badge counts only new alerts.
  • +
  • Manage inspection extensions where they work. The Developer edition moves plugin packages into Inspect › Plugins & Providers and adds versioned local provider profiles with compatible plugin assignment, endpoint validation and Keychain references without persisting raw credentials.
  • +
  • Inspect Live traffic without relying on color or a pointer. The Live chart adds keyboard traversal, VoiceOver details, strict plot hit testing, a solid/circle download series and dashed/diamond upload series, with an executable light/dark palette gate.
  • +
  • Keep redesigned paths within measured limits. Fail-capable numeric budgets cover startup, a 600-edge graph, a 10,001-row History query, a 10,000-alert query, a 1,000-agent inventory and 30,000 built-in plugin evaluations, with complete diagnostics on failure.
  • +
  • Carry every completed change forward safely. The unpublished 0.17.4, 0.17.5 and 0.17.6 work ships here: Policies preserves each policy type's authoritative editor and evidence, Sources is one Collector–Devices–Coverage document, History clears pointer details without inventing a keyboard selection, and release review receipts remain valid when GitHub hides a trusted reviewer's private organization membership from the workflow token.
  • +
  • Reuse CI only when the evidence is exact. Pull-request app jobs may reuse a successful canonical run only when its full Git tree, app fingerprint, check conclusions, receipt schema and immutable artifact identity match; every invalid or forked case executes the current jobs.
  • +
  • Deploy only the site CI verified. Pages consumes the exact manifested site artifact from successful canonical main CI, with no source checkout or rebuild, while weekly full CodeQL remains in place.
  • +
  • Keep untrusted pull-request refs out of checkouts. Merge compatibility uses GitHub's trusted pull-request event merge commit rather than constructing a checkout ref from pull-request input, resolving code-scanning alert 4 without granting privileged workflow access.
  • +
  • Sign each release once. The immutable tag run builds, signs, notarizes, Gatekeeper-verifies, attests and retains one artifact; credential-free publication can only verify and publish those exact prepared bytes, while accepted Apple tickets survive a bounded multi-minute CloudKit delivery outage without resubmission.
  • +
+
+ +
+

0.17.3

  • Keep every input in one operational home. Capture, Devices and Coverage are now Collector, Devices and Coverage tabs inside Sources, with a shared health summary and a step-by-step recovery card when traffic is not arriving.
  • Tune alerts where their results appear. Detection controls now live beside the alert list under Alerts › Detection, while database retention, location and deletion live in Settings › Data & Privacy.
  • @@ -1592,7 +1608,7 @@

    What's new

diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 31d203fa..a231814c 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -1,55 +1,55 @@ - https://flowlight.xinbetween.com/about/2026-11-04 - https://flowlight.xinbetween.com/docs/2026-11-04 - https://flowlight.xinbetween.com/2026-11-04 - https://flowlight.xinbetween.com/plugins/2026-11-04 - https://flowlight.xinbetween.com/privacy/2026-11-04 - https://flowlight.xinbetween.com/releases/2026-11-04 - https://flowlight.xinbetween.com/threat-model/2026-11-04 - https://flowlight.xinbetween.com/de/about/2026-11-04 - https://flowlight.xinbetween.com/de/docs/2026-11-04 - https://flowlight.xinbetween.com/de/2026-11-04 - https://flowlight.xinbetween.com/de/privacy/2026-11-04 - https://flowlight.xinbetween.com/de/threat-model/2026-11-04 - https://flowlight.xinbetween.com/es/about/2026-11-04 - https://flowlight.xinbetween.com/es/docs/2026-11-04 - https://flowlight.xinbetween.com/es/2026-11-04 - https://flowlight.xinbetween.com/es/privacy/2026-11-04 - https://flowlight.xinbetween.com/es/threat-model/2026-11-04 - https://flowlight.xinbetween.com/fr/about/2026-11-04 - https://flowlight.xinbetween.com/fr/docs/2026-11-04 - https://flowlight.xinbetween.com/fr/2026-11-04 - https://flowlight.xinbetween.com/fr/privacy/2026-11-04 - https://flowlight.xinbetween.com/fr/threat-model/2026-11-04 - https://flowlight.xinbetween.com/it/about/2026-11-04 - https://flowlight.xinbetween.com/it/docs/2026-11-04 - https://flowlight.xinbetween.com/it/2026-11-04 - https://flowlight.xinbetween.com/it/privacy/2026-11-04 - https://flowlight.xinbetween.com/it/threat-model/2026-11-04 - https://flowlight.xinbetween.com/pt-PT/about/2026-11-04 - https://flowlight.xinbetween.com/pt-PT/docs/2026-11-04 - https://flowlight.xinbetween.com/pt-PT/2026-11-04 - https://flowlight.xinbetween.com/pt-PT/privacy/2026-11-04 - https://flowlight.xinbetween.com/pt-PT/threat-model/2026-11-04 - https://flowlight.xinbetween.com/ja/about/2026-11-04 - https://flowlight.xinbetween.com/ja/docs/2026-11-04 - https://flowlight.xinbetween.com/ja/2026-11-04 - https://flowlight.xinbetween.com/ja/privacy/2026-11-04 - https://flowlight.xinbetween.com/ja/threat-model/2026-11-04 - https://flowlight.xinbetween.com/zh-Hans/about/2026-11-04 - https://flowlight.xinbetween.com/zh-Hans/docs/2026-11-04 - https://flowlight.xinbetween.com/zh-Hans/2026-11-04 - https://flowlight.xinbetween.com/zh-Hans/privacy/2026-11-04 - https://flowlight.xinbetween.com/zh-Hans/threat-model/2026-11-04 - https://flowlight.xinbetween.com/zh-Hant/about/2026-11-04 - https://flowlight.xinbetween.com/zh-Hant/docs/2026-11-04 - https://flowlight.xinbetween.com/zh-Hant/2026-11-04 - https://flowlight.xinbetween.com/zh-Hant/privacy/2026-11-04 - https://flowlight.xinbetween.com/zh-Hant/threat-model/2026-11-04 - https://flowlight.xinbetween.com/ko/about/2026-11-04 - https://flowlight.xinbetween.com/ko/docs/2026-11-04 - https://flowlight.xinbetween.com/ko/2026-11-04 - https://flowlight.xinbetween.com/ko/privacy/2026-11-04 - https://flowlight.xinbetween.com/ko/threat-model/2026-11-04 + https://flowlight.xinbetween.com/about/2026-11-05 + https://flowlight.xinbetween.com/docs/2026-11-05 + https://flowlight.xinbetween.com/2026-11-05 + https://flowlight.xinbetween.com/plugins/2026-11-05 + https://flowlight.xinbetween.com/privacy/2026-11-05 + https://flowlight.xinbetween.com/releases/2026-11-05 + https://flowlight.xinbetween.com/threat-model/2026-11-05 + https://flowlight.xinbetween.com/de/about/2026-11-05 + https://flowlight.xinbetween.com/de/docs/2026-11-05 + https://flowlight.xinbetween.com/de/2026-11-05 + https://flowlight.xinbetween.com/de/privacy/2026-11-05 + https://flowlight.xinbetween.com/de/threat-model/2026-11-05 + https://flowlight.xinbetween.com/es/about/2026-11-05 + https://flowlight.xinbetween.com/es/docs/2026-11-05 + https://flowlight.xinbetween.com/es/2026-11-05 + https://flowlight.xinbetween.com/es/privacy/2026-11-05 + https://flowlight.xinbetween.com/es/threat-model/2026-11-05 + https://flowlight.xinbetween.com/fr/about/2026-11-05 + https://flowlight.xinbetween.com/fr/docs/2026-11-05 + https://flowlight.xinbetween.com/fr/2026-11-05 + https://flowlight.xinbetween.com/fr/privacy/2026-11-05 + https://flowlight.xinbetween.com/fr/threat-model/2026-11-05 + https://flowlight.xinbetween.com/it/about/2026-11-05 + https://flowlight.xinbetween.com/it/docs/2026-11-05 + https://flowlight.xinbetween.com/it/2026-11-05 + https://flowlight.xinbetween.com/it/privacy/2026-11-05 + https://flowlight.xinbetween.com/it/threat-model/2026-11-05 + https://flowlight.xinbetween.com/pt-PT/about/2026-11-05 + https://flowlight.xinbetween.com/pt-PT/docs/2026-11-05 + https://flowlight.xinbetween.com/pt-PT/2026-11-05 + https://flowlight.xinbetween.com/pt-PT/privacy/2026-11-05 + https://flowlight.xinbetween.com/pt-PT/threat-model/2026-11-05 + https://flowlight.xinbetween.com/ja/about/2026-11-05 + https://flowlight.xinbetween.com/ja/docs/2026-11-05 + https://flowlight.xinbetween.com/ja/2026-11-05 + https://flowlight.xinbetween.com/ja/privacy/2026-11-05 + https://flowlight.xinbetween.com/ja/threat-model/2026-11-05 + https://flowlight.xinbetween.com/zh-Hans/about/2026-11-05 + https://flowlight.xinbetween.com/zh-Hans/docs/2026-11-05 + https://flowlight.xinbetween.com/zh-Hans/2026-11-05 + https://flowlight.xinbetween.com/zh-Hans/privacy/2026-11-05 + https://flowlight.xinbetween.com/zh-Hans/threat-model/2026-11-05 + https://flowlight.xinbetween.com/zh-Hant/about/2026-11-05 + https://flowlight.xinbetween.com/zh-Hant/docs/2026-11-05 + https://flowlight.xinbetween.com/zh-Hant/2026-11-05 + https://flowlight.xinbetween.com/zh-Hant/privacy/2026-11-05 + https://flowlight.xinbetween.com/zh-Hant/threat-model/2026-11-05 + https://flowlight.xinbetween.com/ko/about/2026-11-05 + https://flowlight.xinbetween.com/ko/docs/2026-11-05 + https://flowlight.xinbetween.com/ko/2026-11-05 + https://flowlight.xinbetween.com/ko/privacy/2026-11-05 + https://flowlight.xinbetween.com/ko/threat-model/2026-11-05 diff --git a/docs/threat-model/index.html b/docs/threat-model/index.html index 1134aa04..74717cfe 100644 --- a/docs/threat-model/index.html +++ b/docs/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -231,7 +231,7 @@

Reporting a vulnerability

diff --git a/docs/zh-Hans/about/index.html b/docs/zh-Hans/about/index.html index a8036237..d75aa33b 100644 --- a/docs/zh-Hans/about/index.html +++ b/docs/zh-Hans/about/index.html @@ -48,7 +48,7 @@ - + @@ -152,7 +152,7 @@

致谢

diff --git a/docs/zh-Hans/docs/index.html b/docs/zh-Hans/docs/index.html index a5624183..c2806e88 100644 --- a/docs/zh-Hans/docs/index.html +++ b/docs/zh-Hans/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

文档

使用 Flowlight

-

从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.17.3,macOS 15 或更高版本。

+

从第一次启动到调整代理规则,需要的都在这里。Flowlight 0.17.7,macOS 15 或更高版本。

@@ -166,11 +166,11 @@

各个视图

- - + +
PedidoEnviado paraO que contémQuando
Procura de atualizaçõesapi.github.comUm pedido HTTPS normal pela versão mais recente; o GitHub vê o seu endereço IP e o agente de utilizador Flowlight/<version>Na abertura e uma vez por dia, a partir da 0.1.2. Interruptor para desativar em Definições › Geral
Transferência da atualizaçãogithub.comA imagem de disco e o seu ficheiro de soma de verificaçãoSó quando clica em Transferir e abrir
Atualização oficial do pacote de regrasflowlight.xinbetween.comUm pedido HTTPS padrão ao feed oficial fixo. O servidor vê o seu endereço IP, o agente do utilizador Flowlight/<version> e os validadores condicionais de cache; não recebe tráfego, definições dos pacotes nem qualquer identificador do dispositivoApós o arranque, quando for necessário, e depois, no máximo, uma vez por dia enquanto funcionar normalmente. Ativa por predefinição; opção para desativar e Procurar agora manualmente em Regras › Pacotes
Atualização oficial do pacote de regrasflowlight.xinbetween.comUm pedido HTTPS padrão ao feed oficial fixo. O servidor vê o seu endereço IP, o agente do utilizador Flowlight/<version> e os validadores condicionais de cache; não recebe tráfego, definições dos pacotes nem qualquer identificador do dispositivoApós o arranque, quando for necessário, e depois, no máximo, uma vez por dia enquanto funcionar normalmente. Ativa por predefinição; opção para desativar e Procurar agora manualmente em Políticas › Pacotes geridos
DNS inversoO resolvedor de DNS que tem configuradoUma consulta do nome registado de um endereço IP, quando não foi visto nenhum nome de anfitriãoConforme necessário, no modo de captura por amostrador, que é a predefinição até a extensão de rede estar a correr. Sem interruptor para desligar: sem isto, o amostrador não consegue dar nome à maioria das ligações. A extensão de rede dá-lhes nome a partir do próprio tráfego e não faz esta consulta
Consulta do proprietário da redeTeam Cymru, por DNSEndereços IP públicos sem nome de anfitrião conhecido. Os endereços privados e reservados nunca saem do seu MacAtivada por predefinição. Interruptor para desativar em Captura › Identificar os proprietários da rede
Perguntar com um modelo alojadoO fornecedor que escolher: Anthropic, OpenAI, Google Gemini ou um endpoint compatívelA sua pergunta e os resultados devolvidos pelas consultas de leitura do Flowlight escolhidas pelo modelo. A base de dados de tráfego em si não é enviadaSó quando escolhe um fornecedor alojado e submete uma pergunta. O ecrã Perguntar mostra os pedidos transmitidos
警报⌘5所有看起来不寻常的事,带上应用、目标和触发规则的那个数字。
提问⌘6用平常的话问你自己的流量,由本地模型或托管模型从记录下来的历史里作答。
检查⌘7 · Developer ID可选:你交给 Flowlight 转发的那些代理的解密请求,以及它们背后的工具调用。搜索能深入请求和响应正文。
规则⌘8 · Developer ID
⌘7 · App Store
你设过的每一条允许列表和拒绝,各自管到哪里,以及实际拦下了什么。
来源⌘9 · Developer ID
⌘8 · App Store
在三个标签页中显示收集器状态、设备通道和命名覆盖范围。
策略⌘8 · Developer ID
⌘7 · App Store
每项已配置的策略、是否真正生效,以及统一的只读活动记录。
来源⌘9 · Developer ID
⌘8 · App Store
收集器状态与恢复、可选设备通道和覆盖范围,集中在一张可滚动的运维页面中。
-

快捷键遵循可见的侧边栏顺序。Developer ID 版本在 ⌘7 包含检查;App Store 版本省略检查,因此规则和来源会移到 ⌘7 和 ⌘8。

-
快捷键遵循可见的侧边栏顺序。Developer ID 版本在 ⌘7 包含检查;App Store 版本省略检查,因此策略和来源会移到 ⌘7 和 ⌘8。

+
实时视图:一列应用,各自带着当前的发送与接收速率,以及最忙的目标。
实时,Flowlight 打开时进入的那个视图。

在历史里,把指针停在柱子上,就会打开一张跟随指针、可滚动的卡片,列出贡献流量的应用;点击柱子不会放大。双击任意一行可把历史收窄到那一项。

@@ -208,7 +208,7 @@

工具与 MCP 服务器

每个域名都标明是从这四种里的哪一种知道的。带着好几个标签的域名,是比只带一个标签更强的事实:配置文件里写了而且确实 连过,比其中任何一项单独说得更多;只被配置过的域名从未被访问过,而只被观察到的域名从来没有写在任何你本可以读到的地方。

每个域名给出两种拒绝:对这个代理,覆盖该代理以及它启动的工具;或者在所有地方,覆盖这台 Mac 上的 - 每一个应用。两者写下的都是普通规则,所以违规流水会报告它们,规则编辑器会解释它们——而且正因为它们是普通规则, + 每一个应用。两者写下的都是普通规则,所以活动会报告它们,规则编辑器会解释它们——而且正因为它们是普通规则, 编辑器里的模拟会在你把它打开之前告诉你,它本会拒绝掉什么。

有一类域名没有按钮。模型提供方自己去连的连接器——在请求正文里声明,从提供方那一侧连接——根本不会碰到这台 Mac,所以这里的 任何规则都够不到它。那一行会把这件事说出来,而不是递给你一个悄悄什么都不做的拒绝。如果同一个主机在本地也被配置过,或者 @@ -373,16 +373,16 @@

模拟响应

连接:模拟改变的是答案,而不是连接能否建立,所以“这个 API 返回 500”行得通,而“这个 API 完全不可达”不行。

-

规则

-

允许列表是某一个代理关于它可以去哪里的说法。规则是它的通用版本:它指明一个 +

策略

+

策略集中列出 Flowlight 能执行的所有管控:普通的允许或阻止规则、代理允许列表、托管包,以及在支持 HTTPS 检查时的护栏、模拟响应、请求改写和响应转换。每一行都会区分配置状态与实际效果:生效、仅报告、监视、等待、已排期、暂停、已用完、正在应用、需要检查、不可用、不兼容或失败。选择一行会打开负责该策略的对应编辑器;“策略”不会取代或混淆这些功能各自独立的授权和持久化方式。

+

允许列表是某一个代理关于它可以去哪里的说法。普通规则是它的通用版本:它指明一个 对象——应用、代理、目标、URL,或其中两者的组合——并决定阻止 还是允许。在实时、历史、AI 代理或检查里右键任意一行,就能针对你正看着的东西写一条; - 它们全都落在规则界面上的同一个编辑器里,因为一条在看不见的地方生效的规则, - 会是一条你再也找不到的规则。

+ 每条规则都能在“策略”中找到,并会在其权威编辑器中打开。

模板。共 22 个,分为五组——阻止代理访问软件包仓库或其他 模型提供方;封住数据外流的常见途径(粘贴站点、webhook 接收器、匿名文件投放、隧道、DNS over HTTPS);产品分析与崩溃上报;工作时段策略;以及一次性的“先把这个拦一小时,我弄清它 - 是什么”。可以从规则工具栏浏览它们,也可以在实时、历史、AI 代理或检查的任意一行上右键, + 是什么”。可以从策略工具栏浏览它们,也可以在实时、历史、AI 代理或检查的任意一行上右键, 把某个模板应用到你正看着的东西上。

每个模板在动手之前都会说明它要做什么。凡是会写入多条规则的,都会打开一张卡片,列出每个 主机和时段;凡是永久生效的,都会打开预填好的编辑器;而只有会自行撤销的规则——一 @@ -403,8 +403,7 @@

规则

地址和端口,对路径一无所知,而且只在扩展模式下有效。HTTPS 检查拒绝的是单个请求,所以它能匹配 URL,并用代理读得懂的状态码作答,而不是一个不声不响就断掉的套接字。nettop 采样器 完全无法拒绝任何东西。当前配置无法执行的规则会在列表里明说,而不是摆在那里装作已经生效。

-

第二个标签页是违规流水:每一条由规则判定的连接,带上应用、它要去哪里、是哪条 - 规则判定的,以及那究竟是一次拒绝,还是一次放它通过的例外。

+

第二个视图是只读的活动:普通规则判定、允许列表违规、托管包事件,以及各类检查策略的效果,都按时间排列在一处。它会适配现有的权威记录,而不是把历史改写成一种笼统的事件模型;App Store 版本会省略依赖检查的策略类型及其保留的活动。

它本来会做什么。阻止的规则写起来容易,打开却让人害怕,因为你通常最先了解到的是它 弄坏了什么——而且是后来才知道的,从别处某个东西失败上知道的。规则编辑器先回答这个问题:它拿记录 @@ -422,7 +421,7 @@

规则

的最早的流量:一条只对照了两小时历史的规则,就是只对照了两小时历史。

托管广告和跟踪器包

-

规则 › 包包含三个官方主机名列表:广告、跨站跟踪和隐私与遥测。它们从一开始就可见,并且默认均为关闭状态。阻止功能需要网络扩展;nettop 采样器只能观察。

+

策略 › 托管包包含三个官方主机名列表:广告、跨站跟踪和隐私与遥测。它们从一开始就可见,并且默认均为关闭状态。阻止功能需要网络扩展;nettop 采样器只能观察。

仅限官方签名更新。 Flowlight 会检查一个固定的 HTTPS 源,以查找由发布者签名的新版目录。自动检查默认开启,可在此处关闭。签名可验证内容,但不会启用内容:新包会保持关闭;如果目标发生变化,已启用的包将停止执行,直到你审查确切的新版本并按下保存。服务中断期间,已接受的内容可能会继续作为最后已知正常版本保留,并明确显示为已过期。这些是主机名级别的网络阻止,无法移除 DOM 或布局中的空白、浏览器叠加层、脚本生成的弹出窗口或原生应用窗口。

当 HTTPS 检查将应用经由 Flowlight 的代理转发时,普通目标规则和已启用的托管包会在打开任何源站连接之前,使用原应用的身份作出判定。对于固定了证书或始终不解密的 CONNECT 隧道,这一判定按主机名进行;代理允许列表仍只适用于直接经过网络扩展的路径。如果所有者或主机名未知,则会允许连接。

@@ -471,7 +470,8 @@

代理护栏:阻止一个工具、一个服务器或一个

这不是沙盒。被拒绝的工具不再提供给模型,也不再被响应。而仍然拥有 shell 的代理,可以手动做到那个工具本来要做的事。Flowlight 会把它记录下来;它不会假装能阻止。

-

来源:收集器与引擎

+

来源:收集器、设备与覆盖范围

+

来源是一张可滚动的运维页面:先是收集器,接着是标题清晰的设备与覆盖范围分区。状态通知、提问答案、恢复后的窗口和截图链接都会滚动到相关分区并短暂突出显示,同时不隐藏其他内容。

Flowlight 用两种方式之一记录流量,在来源 › 收集器里选择。它们会漏掉的东西不同,在依赖任何 一种之前,值得先了解清楚。

@@ -657,7 +657,7 @@

问题与解答

窗口关闭时 Flowlight 几乎什么都不做:它保留菜单栏速率,每秒写入一行。网络扩展 比作为后备的采样器更省,后者每秒都要启动一个短命的 nettop 进程。
它能阻止连接吗?
-
能,配合网络扩展。代理的允许列表除了警告也可以拒绝,一条规则可以按你指定的时长阻止一个应用、 +
能,配合网络扩展。代理的允许列表除了警告也可以拒绝,一条规则可以按你指定的时长阻止一个应用、 一个目标或一个 URL,而一条护栏可以在模型看到某个工具之前,把它从代理手里 拿走。在你要求之前不会阻止任何东西,而且每次拒绝都带着它的出口。
我需要网络扩展吗?
@@ -712,7 +712,7 @@

限制

diff --git a/docs/zh-Hans/index.html b/docs/zh-Hans/index.html index ec6515b9..fff0c421 100644 --- a/docs/zh-Hans/index.html +++ b/docs/zh-Hans/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

看清应用的网络活动。

brew install --cask xinbetween/tap/flowlight

-

v0.17.3macOS 15+通用架构GPL-3.0无遥测

+

v0.17.7macOS 15+通用架构GPL-3.0无遥测

@@ -127,7 +127,7 @@

看清应用的网络活动。
-
Flowlight 的 AI 活动图。代理卡片经过已归属工具和已配置网关;观测到的 magpie 到 Flowlight 路由随后分支至 LLM 服务和域名。带状线宽度表示所选时段的流量。

查看真实 AI 路由:代理、已归属工具、已配置网关、Flowlight 观测到的代理阶段和精确目标。带状线宽度表示所选时段接收与发送流量的总和。图中为演示数据。

@@ -135,7 +135,7 @@

看清应用的网络活动。
-
Flowlight 的 AI 代理视图。Claude Code、Cursor、Codex 和一个 Python 脚本逐行列出,显示各自的 AI 提供方、发往别处的流量,以及 SMTP、SSH、FTP、大量上传和裸 IP 的风险标记。

AI 代理视图:每个代理的模型提供方,以及它联系过的其他一切。图中为演示数据。

@@ -179,11 +179,11 @@

历史

流量究竟去了哪?从秒到年,按应用、目标或 I

警报

哪里不对?逐应用、讲得清道理的警报,带上目标和阈值。

⌘5
-

规则

我拒绝过什么,真的生效了吗?每条规则、各自阻止了什么,以及每次判定的流水。

⌘8 · Developer ID / ⌘7 · App Store
+

策略

有哪些管控,它们是否生效?每项策略、实际状态,以及统一的活动记录。

⌘8 · Developer ID / ⌘7 · App Store

提问

用日常语言提问,从历史数据里作答——下方列出它执行过的每一条查询。

⌘6
-

来源

数据是否正在到达、多少已有名称、哪些设备通道处于活动状态?把收集器、设备和覆盖范围放在一处。

⌘9 · Developer ID / ⌘8 · App Store
+

来源

数据是否正在到达、哪些设备通道处于活动状态、Flowlight 能解释多少?一张页面从收集器一直看到覆盖范围。

⌘9 · Developer ID / ⌘8 · App Store

检查

可选。代理究竟发出了什么,是哪次工具调用引起的?解密后的请求、工具调用和结果。

⌘7 · Developer ID
@@ -402,16 +402,16 @@

把受支持的工具调用与网络请求对上

默认关闭。打开后,Flowlight 会成为一个本地代理,使用在你 Mac 上生成的专属证书颁发机构。 从受检查的终端启动代理,代理及其工具的受支持 HTTP 流量就会被解密,并附上模型请求的工具调用和返回的结果。

-
Flowlight 的检查视图:一列已解密的 Claude Code 请求及其工具调用,以及 curl 向 paste.example 上传的详情——由运行它的那次 Bash 工具调用发起,请求体里正是上传的笔记
我的笔记去哪了?curl 向 paste.example 的上传,一路追到模型要求执行的那条 Bash 命令, 连它发出的请求体一起。
-
Claude Code 的工具标签页:Bash、Read 和两个 MCP 工具,附带说明以及模型各自调用了多少次
代理提供给模型的每一个工具。代理每一轮都会声明自己的工具,所以 Flowlight 把它们全部列出, 并区分模型真正用过的和从未碰过的。
-
Codex 的 MCP 服务器标签页:一个标为“经由提供方”的 sentry 服务器,以及它的 URL、设为“从不”的审批、允许的工具和它提供的工具
连线路上看不到的那些也算。当代理请求提供方去连接某个 MCP 服务器时,那段流量根本不会经过你的 Mac。 但它的请求会,所以这个服务器、它的 URL、它的审批设置和它的工具依然会出现。
@@ -457,7 +457,7 @@

一个不用你盯着的监控工具

-
Flowlight 里 Claude Code 的详情:带调用次数和一次失败的工具与 MCP 服务器、一个允许列表,以及工具调用标签页,里面列出一次失败的 git push、一次 curl 向 paste.example 的上传及其响应和请求、npm install、一个 GitHub issue、一次文档搜索和一次文件读取,每项都带结果
一个代理,每一次工具调用。Claude Code 启动的 git、curl 和 MCP 服务器产生的流量都算作 Claude Code 的。 打开 HTTPS 检查后,工具调用标签页会显示模型要求了什么、返回了什么,以及每个工具发出的请求: @@ -467,10 +467,10 @@

一个不用你盯着的监控工具

-
历史视图,含每小时接收与发送的柱状图,以及应用、目标和协议的环形图
历史。从秒到年的缩放、三种分组方式,几百个应用也读得清的图表。
-
警报视图,列出代理外泄、邮件、FTP、你离开时活动和流量突增等警报
警报。每一条都点明应用、目标和触发它的那个数字。
@@ -586,7 +586,7 @@

知道什么离开了你的 Mac。

diff --git a/docs/zh-Hans/privacy/index.html b/docs/zh-Hans/privacy/index.html index 6b827754..475efb46 100644 --- a/docs/zh-Hans/privacy/index.html +++ b/docs/zh-Hans/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -124,7 +124,7 @@

应用发出的网络请求

- + @@ -187,7 +187,7 @@

联系方式

diff --git a/docs/zh-Hans/threat-model/index.html b/docs/zh-Hans/threat-model/index.html index f7567b77..82a21644 100644 --- a/docs/zh-Hans/threat-model/index.html +++ b/docs/zh-Hans/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -210,7 +210,7 @@

报告漏洞

diff --git a/docs/zh-Hant/about/index.html b/docs/zh-Hant/about/index.html index cb2ede04..f9068a43 100644 --- a/docs/zh-Hant/about/index.html +++ b/docs/zh-Hant/about/index.html @@ -48,7 +48,7 @@ - + @@ -152,7 +152,7 @@

致謝

diff --git a/docs/zh-Hant/docs/index.html b/docs/zh-Hant/docs/index.html index f0aa9829..c46bfdf2 100644 --- a/docs/zh-Hant/docs/index.html +++ b/docs/zh-Hant/docs/index.html @@ -48,7 +48,7 @@ - + @@ -76,7 +76,7 @@

說明文件

使用 Flowlight

-

從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.17.3,macOS 15 或以上版本。

+

從第一次啟動到調整代理規則,全都在這裡。Flowlight 0.17.7,macOS 15 或以上版本。

@@ -169,11 +169,11 @@

各個畫面

- - + +
请求发往包含什么什么时候
更新检查api.github.com一个查询最新版本的标准 HTTPS 请求;GitHub 会看到你的 IP 地址和 Flowlight/<version> 这个 User-Agent 标识启动时以及每天一次,自 0.1.2 起。关闭开关在设置 › 通用
更新下载github.com磁盘映像及其校验和文件只在你点击“下载并打开”时
官方规则包更新flowlight.xinbetween.com向固定官方源发送的标准 HTTPS 请求。服务器会看到你的 IP 地址、Flowlight/<version>用户代理和缓存条件验证器;不会收到任何流量、包设置或设备标识符启动后在检查到期时执行,此后在运行正常的情况下最多每天执行一次。默认开启;可在规则 › 包中关闭或手动选择立即检查
官方规则包更新flowlight.xinbetween.com向固定官方源发送的标准 HTTPS 请求。服务器会看到你的 IP 地址、Flowlight/<version>用户代理和缓存条件验证器;不会收到任何流量、包设置或设备标识符启动后在检查到期时执行,此后在运行正常的情况下最多每天执行一次。默认开启;可在策略 › 托管包中关闭或手动选择立即检查
反向 DNS你配置的 DNS 解析器在没有看到主机名时,查询某个 IP 地址登记的名称按需进行,在采样器捕获模式下,也就是网络扩展开始运行之前的默认模式。没有关闭开关:没有它,采样器就没法给大多数连接命名。网络扩展从流量本身给它们命名,因此不做这件事
网络所有者查询Team Cymru,通过 DNS没有已知主机名的公网 IP 地址。私有地址和保留地址绝不离开你的 Mac默认开启。关闭开关在捕获 › 识别网络所有者
用托管模型提问你选择的提供方:Anthropic、OpenAI、Google Gemini 或兼容的端点你的问题,以及模型选中的那些只读 Flowlight 查询所返回的结果。流量数据库本身不会被上传只在你选择了托管提供方并提交问题时。提问界面会显示发送出去的请求
警示⌘5所有看起來不尋常的事,附上 App、目標,以及觸發規則的那個數字。
提問⌘6用平常的話問你自己的流量,由本機模型或託管模型從記錄下來的歷史裡作答。
檢查⌘7 · Developer ID選用:你轉送經過 Flowlight 的代理,其解密後的請求,以及背後的工具呼叫。搜尋也會查進請求與回應的內文。
規則⌘8 · Developer ID
⌘7 · App Store
你設過的每一條允許清單和拒絕,各自管到哪裡,以及實際攔下了什麼。
來源⌘9 · Developer ID
⌘8 · App Store
在三個分頁中顯示收集器狀態、裝置頻道與命名涵蓋範圍。
政策⌘8 · Developer ID
⌘7 · App Store
每項已設定的政策、是否真的生效,以及整合後的唯讀活動記錄。
來源⌘9 · Developer ID
⌘8 · App Store
收集器狀態與復原、選用的裝置通道和涵蓋範圍,集中在一張可捲動的操作頁面中。
-

快速鍵遵循可見的側邊欄順序。Developer ID 版本在 ⌘7 包含「檢查」;App Store 版本省略它,因此「規則」與「來源」會移到 ⌘7 和 ⌘8。

-
快速鍵遵循可見的側邊欄順序。Developer ID 版本在 ⌘7 包含「檢查」;App Store 版本省略它,因此「政策」與「來源」會移到 ⌘7 和 ⌘8。

+
即時畫面:一列 App,各自帶著目前的傳送與接收速率,以及最忙的目標。
即時,Flowlight 開啟時的那個畫面。

在「歷史」中,把指標移到長條上,就會開啟一張跟隨指標、可捲動的卡片,列出貢獻流量的 App;點選長條不會放大。任一列雙按可把歷史縮小到那一列。

@@ -211,7 +211,7 @@

工具與 MCP 伺服器

一組,每個網域都標明是從這四種裡的哪一種知道的。帶著好幾個標籤的網域,是比只帶一個標籤更強的事實:設定檔裡寫了而且確實連過, 比其中任何一項單獨說得更多;只被設定過的網域從來沒有被連上,而只被觀察到的網域從來沒有寫在任何你本來可以讀到的地方。

每個網域提供兩種拒絕:「對這個代理」,涵蓋該代理以及它啟動的工具;或是「在所有地方」,涵蓋這台 Mac 上的 - 每一個 App。兩者寫下的都是普通規則,所以「違規紀錄」會報告它們,規則編輯器會解釋它們 — 而且正因為它們是普通規則, + 每一個 App。兩者寫下的都是普通規則,所以「活動」會報告它們,規則編輯器會解釋它們 — 而且正因為它們是普通規則, 編輯器裡的模擬會在你把它打開之前告訴你,它本來會拒絕掉什麼。

有一類網域沒有按鈕。模型供應商自己去連的連接器 — 在請求內文裡聲明,從供應商那一側連過去 — 根本不會碰到這台 Mac,所以這裡的 任何規則都碰不到它。那一列會把這件事說出來,而不是遞給你一個默默什麼都不做的拒絕。如果同一個主機在本機也有設定,或者 @@ -378,16 +378,16 @@

模擬回應

會被連線:模擬改變的是答案,而不是連線能不能建立,所以「API 回傳 500」做得到,「API 完全連不上」則做不到。

-

規則

-

允許清單講的是某一個代理可以去哪裡。規則是它的通用版本:它會指名一個 +

政策

+

政策集中列出 Flowlight 能套用的所有管控:一般的允許或封鎖規則、代理允許清單、受管理套件,以及在支援 HTTPS 檢查時的防護機制、模擬回應、請求改寫和回應轉換。每一列都會區分設定狀態與實際效果:生效、僅回報、監看、等待、已排程、暫停、已用完、正在套用、需要檢閱、無法使用、不相容或失敗。選取一列會開啟負責該政策的對應編輯器;「政策」不會取代或混淆這些功能各自獨立的授權與持久化方式。

+

允許清單講的是某一個代理可以去哪裡。一般規則是它的通用版本:它會指名一個 對象 — 一個 App、一個代理、一個目標、一個 URL,或其中兩者的組合 — 然後說封鎖 或允許。在「即時」、「歷史」、「AI 代理」或「檢查」的任一列上按右鍵,就能針對你正在看的東西寫一條; - 每一條都會進到「規則」畫面的同一個編輯器,因為在你看不到的地方生效的規則, - 會是一條你再也找不到的規則。

+ 每條規則都能在「政策」中找到,並會在其權威編輯器中開啟。

範本。共二十二個,分成五組 — 阻止代理連到套件登錄或其他 模型供應商、關上資料外流的常見途徑(貼文網站、webhook 接收器、匿名檔案上傳、通道、DNS over HTTPS)、產品分析與當掉回報、工作時段政策,以及一次性的「先封鎖一小時,讓我弄清楚 - 這是什麼」。你可以從「規則」工具列瀏覽它們,或在「即時」、「歷史」、「AI 代理」或「檢查」的任一列上按右鍵, + 這是什麼」。你可以從「政策」工具列瀏覽它們,或在「即時」、「歷史」、「AI 代理」或「檢查」的任一列上按右鍵, 把範本套用到你正在看的東西上。

每個範本都會在動手之前先說明它要做什麼。會寫入多條規則的,會開一張卡片列出每一台 主機和時程;任何永久性的,會開啟已預先填好的編輯器;而只有會自己失效的規則 — @@ -408,8 +408,7 @@

規則

位址和連接埠,但完全不知道路徑,而且只在延伸功能模式下有效。HTTPS 檢查拒絕的是單一請求,所以它能比對 URL,並回覆一個代理讀得懂的狀態,而不是一個莫名死掉的 socket。nettop 取樣器完全無法 拒絕任何東西。目前的設定執行不了的規則,會在列表中說明這一點,而不是擺在那裡看起來像已生效。

-

第二個標籤頁是違規紀錄:每一條由規則決定的連線,包含 App、它要去哪裡、是哪一條 - 規則決定的,以及那是一次拒絕,還是一個放行的例外。

+

第二個畫面是唯讀的活動:一般規則判定、允許清單違規、受管理套件事件,以及各類檢查政策的效果,都依時間排列在一起。它會轉接現有的權威記錄,而不是把歷史改寫成一種籠統的事件模型;App Store 版本會省略依賴檢查的政策類型及其保留的活動。

它本來會做什麼。封鎖的規則寫起來容易,打開卻令人害怕,因為你通常最先知道的是它 弄壞了什麼 — 而且是後來才知道,從別處某個東西失敗上知道的。規則編輯器先回答這個問題:它拿記錄 @@ -427,7 +426,7 @@

規則

它能看到的最早的流量:一條只對照了兩小時歷史的規則,就是只對照了兩小時歷史。

受管理的廣告與追蹤器套件

-

規則 › 套件包含三個官方主機名稱清單:廣告、跨網站追蹤和隱私權與遙測。它們從一開始便可見,且預設全部關閉。封鎖功能需要網路延伸功能;nettop 取樣器只能觀察。

+

政策 › 受管理套件包含三個官方主機名稱清單:廣告、跨網站追蹤和隱私權與遙測。它們從一開始便可見,且預設全部關閉。封鎖功能需要網路延伸功能;nettop 取樣器只能觀察。

僅限官方簽署的更新。 Flowlight 會檢查一個固定的 HTTPS 來源,以尋找由發佈者簽署的新版目錄。自動檢查預設開啟,可在此處關閉。簽章可驗證內容,但不會啟用內容:新套件會保持關閉;如果目的地有所變更,已啟用的套件將停止執行,直到你檢閱確切的新版本並按下儲存。服務中斷期間,已接受的內容可能會繼續保留為已知正常的最後版本,並明確顯示為已過時。這些是主機名稱層級的網路封鎖,無法移除 DOM 或版面配置中的空白、瀏覽器覆疊層、由指令碼產生的彈出式視窗或原生應用程式視窗。

當 HTTPS 檢查將 App 轉送經過 Flowlight 的代理伺服器時,一般目的地規則和已啟用的受管理套件會在開啟任何來源伺服器連線之前,使用原 App 的身分作出判定。對於使用憑證綁定或始終不解密的 CONNECT 通道,這項判定會按主機名稱進行;代理的允許清單仍只適用於直接經過網路延伸功能的路徑。如果擁有者或主機名稱未知,則會允許連線。

@@ -476,7 +475,8 @@

代理防護機制:封鎖一個工具、一個伺服器或

這不是沙箱。被拒絕的工具不會再被提供,也不會再得到回應。但仍然有 shell 的代理,可以自己動手做那個工具原本要做的事。Flowlight 會把那件事記錄下來;它不會假裝自己能阻止。

-

來源:收集器與引擎

+

來源:收集器、裝置與涵蓋範圍

+

來源是一張可捲動的操作頁面:先是收集器,接著是標題清楚的裝置與涵蓋範圍區段。狀態通知、提問答案、還原後的視窗和螢幕截圖連結都會捲動到相關區段並短暫標示,同時不隱藏其他內容。

Flowlight 用兩種方式之一記錄流量,在「來源 › 收集器」中選擇。兩者會漏掉的東西不同,在你倚賴 任何一種之前,值得先了解。

@@ -662,7 +662,7 @@

常見問答

視窗關著時,Flowlight 幾乎什麼都不做:只維持選單列上的速率,並每秒寫入一列資料。網路延伸功能 比備用的取樣器更省,後者每秒都會啟動一個短命的 nettop 程序。
它能封鎖連線嗎?
-
可以,搭配網路延伸功能。代理的允許清單除了警示也能拒絕,規則可以封鎖一個 App、 +
可以,搭配網路延伸功能。代理的允許清單除了警示也能拒絕,規則可以封鎖一個 App、 一個目標或一個 URL,時間長短由你決定,而防護機制可以在工具送到模型面前之前, 先把它從代理手上拿走。在你要求之前不會封鎖任何東西,而每一次拒絕都帶著它的退路。
我一定要用網路延伸功能嗎?
@@ -717,7 +717,7 @@

限制

diff --git a/docs/zh-Hant/index.html b/docs/zh-Hant/index.html index 3390adf6..58fa286e 100644 --- a/docs/zh-Hant/index.html +++ b/docs/zh-Hant/index.html @@ -48,7 +48,7 @@ - + @@ -97,7 +97,7 @@

看見每個 App 的網路活動。
brew install --cask xinbetween/tap/flowlight

-

v0.17.3macOS 15+通用架構GPL-3.0無遙測

+

v0.17.7macOS 15+通用架構GPL-3.0無遙測

@@ -127,7 +127,7 @@

看見每個 App 的網路活動。
-
Flowlight 的 AI 活動圖。代理卡片經過已歸屬工具和已設定閘道;觀測到的 magpie 至 Flowlight 路由隨後分支至 LLM 服務和網域。帶狀線寬度表示所選時段的流量。

查看真實 AI 路由:代理、已歸屬工具、已設定閘道、Flowlight 觀測到的代理階段和精確目的地。帶狀線寬度表示所選時段接收與傳送流量的總和。圖中為示範資料。

@@ -135,7 +135,7 @@

看見每個 App 的網路活動。
-
Flowlight 的「AI 代理」畫面。Claude Code、Cursor、Codex 和一個 Python 指令碼列在其中,顯示各自的 AI 供應商、傳往其他地方的流量,以及 SMTP、SSH、FTP、大量上傳和裸 IP 的風險標記。

「AI 代理」畫面:每個代理的模型供應商,以及它連線過的其他所有目標。圖中為示範資料。

@@ -179,11 +179,11 @@

歷史

流量都到哪去了?從秒到年,依 App、目標或 IP

警示

哪裡看起來不對?每個 App 都有說得清楚的警示,附上目標和閾值。

⌘5
-

規則

我拒絕了什麼,真的有在擋嗎?每一條規則、各自封鎖過什麼,以及每一次決定的紀錄。

⌘8 · Developer ID / ⌘7 · App Store
+

政策

有哪些管控,又是否真的生效?每項政策、實際狀態,以及整合後的活動記錄。

⌘8 · Developer ID / ⌘7 · App Store

提問

用日常語言提問,從歷史記錄裡得到答案 — 下方還列出它跑過的每一條查詢。

⌘6
-

來源

資料是否正在到達、有多少已有名稱、哪些裝置通道正在使用?把收集器、裝置和涵蓋範圍放在同一處。

⌘9 · Developer ID / ⌘8 · App Store
+

來源

資料是否正在到達、哪些裝置通道正在使用、Flowlight 能交代多少?一張頁面從收集器一路看到涵蓋範圍。

⌘9 · Developer ID / ⌘8 · App Store

檢查

選用功能。代理實際送出了什麼,又是哪一次工具呼叫造成的?解密後的請求、工具呼叫、結果。

⌘7 · Developer ID
@@ -403,16 +403,16 @@

把支援的工具呼叫和網路請求對起來

單位。從受檢查的「終端機」啟動代理,它和它的工具所產生的支援 HTTP 流量就會被解密,連同模型要求的 工具呼叫和回傳的結果。

-
Flowlight 的「檢查」畫面:一份解密後的 Claude Code 請求列表及其工具呼叫,以及 curl 上傳到 paste.example 的詳細內容 — 那次上傳由執行它的 Bash 工具呼叫發出,請求內文中還有上傳的筆記
我的筆記去哪了? curl 上傳到 paste.example,追溯到模型要求執行的 Bash 指令,連同它 送出的內文。
-
Claude Code 的「工具」標籤頁:Bash、Read 和兩個 MCP 工具,附上說明以及模型各呼叫了幾次
代理提供給模型的每一個工具。 代理每一輪都會宣告自己的工具,所以 Flowlight 會全部列出來,包含模型 真正用過的,以及完全沒碰過的。
-
Codex 的「MCP 伺服器」標籤頁:一個標示為「透過供應商」的 sentry 伺服器,包含它的 URL、設為永不核准的核准設定、允許的工具,以及它提供的工具
連你在線路上看不到的也算。 當代理請供應商去連接某個 MCP 伺服器,那段流量 根本不會經過你的 Mac。但代理的請求會,所以那個伺服器、它的 URL、它的核准設定和它的工具仍然會出現。
@@ -458,7 +458,7 @@

一個不用你盯著的監控工具

-
Flowlight 中 Claude Code 的詳細資料:工具與 MCP 伺服器及其呼叫次數和一次失敗、一份允許清單,以及「工具呼叫」標籤頁 — 其中列出一次失敗的 git push、一次 curl 上傳到 paste.example 及其回應與請求、npm install、一個 GitHub issue、一次說明文件搜尋和一次檔案讀取,每一項都附上結果
一個代理,每一次工具呼叫。 Claude Code 啟動的 git、curl 和 MCP 伺服器所產生的流量,都算成 Claude Code 的。 開啟 HTTPS 檢查後,工具呼叫標籤頁會顯示模型要求了什麼、回來了什麼,以及各個工具發出的請求: @@ -468,10 +468,10 @@

一個不用你盯著的監控工具

-
「歷史」畫面:每小時接收與傳送的長條圖,以及 App、目標和協定的環圈圖
歷史。 從秒到年的縮放、三種分組方式,即使有上百個 App,圖表依然看得清楚。
-
「警示」畫面:列出代理資料外洩、郵件、FTP、離開期間活動和流量尖峰等警示
警示。 每一則都指出 App、目標,以及觸發它的那個數字。
@@ -587,7 +587,7 @@

知道有什麼離開了你的 Mac。

diff --git a/docs/zh-Hant/privacy/index.html b/docs/zh-Hant/privacy/index.html index 31220b1a..04505ed5 100644 --- a/docs/zh-Hant/privacy/index.html +++ b/docs/zh-Hant/privacy/index.html @@ -48,7 +48,7 @@ - + @@ -124,7 +124,7 @@

這個 App 發出的網路請求

- + @@ -187,7 +187,7 @@

聯絡

diff --git a/docs/zh-Hant/threat-model/index.html b/docs/zh-Hant/threat-model/index.html index ad0221bb..199f92c6 100644 --- a/docs/zh-Hant/threat-model/index.html +++ b/docs/zh-Hant/threat-model/index.html @@ -48,7 +48,7 @@ - + @@ -211,7 +211,7 @@

回報漏洞

diff --git a/packaging/app-store/screenshots/README.md b/packaging/app-store/screenshots/README.md index 35453cb8..97b553f0 100644 --- a/packaging/app-store/screenshots/README.md +++ b/packaging/app-store/screenshots/README.md @@ -17,4 +17,4 @@ The capture gate requires: - every retained image to be exactly 2880×1800 pixels; - English and light appearance for a consistent US storefront set. -Upload the numbered files in `macos/` in filename order. The Store set uses Rules in place of Inspect because the Store product deliberately omits HTTPS inspection. Replace the complete existing App Store Connect screenshot set together; do not mix images from different builds or menu vocabularies. +Upload the numbered files in `macos/` in filename order. The Store set uses Policies in place of Inspect because the Store product deliberately omits HTTPS inspection. Replace the complete existing App Store Connect screenshot set together; do not mix images from different builds or menu vocabularies. diff --git a/packaging/app-store/screenshots/macos/01-connections.png b/packaging/app-store/screenshots/macos/01-connections.png index 0423ff5a..084f46a7 100644 Binary files a/packaging/app-store/screenshots/macos/01-connections.png and b/packaging/app-store/screenshots/macos/01-connections.png differ diff --git a/packaging/app-store/screenshots/macos/02-ai-agents.png b/packaging/app-store/screenshots/macos/02-ai-agents.png index ffb86e40..426d510a 100644 Binary files a/packaging/app-store/screenshots/macos/02-ai-agents.png and b/packaging/app-store/screenshots/macos/02-ai-agents.png differ diff --git a/packaging/app-store/screenshots/macos/03-history.png b/packaging/app-store/screenshots/macos/03-history.png index d0b052eb..720dde82 100644 Binary files a/packaging/app-store/screenshots/macos/03-history.png and b/packaging/app-store/screenshots/macos/03-history.png differ diff --git a/packaging/app-store/screenshots/macos/04-rules.png b/packaging/app-store/screenshots/macos/04-rules.png index 287cb904..abd857cd 100644 Binary files a/packaging/app-store/screenshots/macos/04-rules.png and b/packaging/app-store/screenshots/macos/04-rules.png differ diff --git a/packaging/app-store/screenshots/macos/05-live.png b/packaging/app-store/screenshots/macos/05-live.png index c7f1f9c6..cce3a16a 100644 Binary files a/packaging/app-store/screenshots/macos/05-live.png and b/packaging/app-store/screenshots/macos/05-live.png differ diff --git a/packaging/app-store/screenshots/macos/06-alerts.png b/packaging/app-store/screenshots/macos/06-alerts.png index f3340732..24ab5e7a 100644 Binary files a/packaging/app-store/screenshots/macos/06-alerts.png and b/packaging/app-store/screenshots/macos/06-alerts.png differ diff --git a/packaging/app-store/screenshots/macos/07-agent-detail.png b/packaging/app-store/screenshots/macos/07-agent-detail.png index d2cf5093..0cd53ecd 100644 Binary files a/packaging/app-store/screenshots/macos/07-agent-detail.png and b/packaging/app-store/screenshots/macos/07-agent-detail.png differ diff --git a/packaging/app-store/screenshots/macos/08-agent-tools.png b/packaging/app-store/screenshots/macos/08-agent-tools.png index 3f29fdad..2d756ad1 100644 Binary files a/packaging/app-store/screenshots/macos/08-agent-tools.png and b/packaging/app-store/screenshots/macos/08-agent-tools.png differ diff --git a/packaging/app-store/screenshots/macos/09-mcp-servers.png b/packaging/app-store/screenshots/macos/09-mcp-servers.png index 7be9cd69..e7725681 100644 Binary files a/packaging/app-store/screenshots/macos/09-mcp-servers.png and b/packaging/app-store/screenshots/macos/09-mcp-servers.png differ diff --git a/packaging/app-store/screenshots/macos/manifest.json b/packaging/app-store/screenshots/macos/manifest.json index 43cfdf63..5c253008 100644 --- a/packaging/app-store/screenshots/macos/manifest.json +++ b/packaging/app-store/screenshots/macos/manifest.json @@ -3,56 +3,56 @@ "screenshots": { "01-connections.png": { "height": 1800, - "sha256": "a395b95c0672a0a6c65348132f2c644b7f6452cb359848c9080f59f9bdaba3f1", - "version": "0.17.3", + "sha256": "53c91bfbcc17d1af55a118a200dd735228d7a01d0e32bdf7f8209cd836997799", + "version": "0.17.4", "width": 2880 }, "02-ai-agents.png": { "height": 1800, - "sha256": "df042863dadbd8230774cdd6d6fa806e14be5e3fe2476dfa6f1fe2ea16af60e9", - "version": "0.17.3", + "sha256": "0057280406d7a80a6e28bb333f260feb8cdec4870b7c7de446e59d8847c9c377", + "version": "0.17.4", "width": 2880 }, "03-history.png": { "height": 1800, - "sha256": "1d8786f1e00766c5ec8cdbeabb51b72d25dcf76363170cbd7e88f8d91c0d4250", - "version": "0.17.3", + "sha256": "aecf4e2d9d803767d555f3631d70fc1c6f3f8526e34ec2d71a1cc82a6556d320", + "version": "0.17.4", "width": 2880 }, "04-rules.png": { "height": 1800, - "sha256": "c6702955fb72c6f5953f3fb102c0f184199f7418cc4984a90f1791ccfd880f14", - "version": "0.17.3", + "sha256": "a477dfd351921321f41bc5be88c9465820b14fb1b177a896530e21193c2fa833", + "version": "0.17.4", "width": 2880 }, "05-live.png": { "height": 1800, - "sha256": "5f556d28d8fe5cefd0c51419c18b0d34a93bd6eb771ab433d810dd62f46cee55", - "version": "0.17.3", + "sha256": "e7d351a00a431c34661b795d61b22cdf2dbb0ad02da19b94d64b2637dc8d2ffa", + "version": "0.17.4", "width": 2880 }, "06-alerts.png": { "height": 1800, - "sha256": "bf4fe86ebc5e996837537ff9baed8a43cd4f332807f18c3493424b563f1f5372", - "version": "0.17.3", + "sha256": "54d4aa50adf2feb55547086f16f02744969b448258f6a9dc57461e4c973c092b", + "version": "0.17.4", "width": 2880 }, "07-agent-detail.png": { "height": 1800, - "sha256": "7090f56a0716a4de06831c7f43bc1bde0c7e2d13d521155ad671c7bc8b95cece", - "version": "0.17.3", + "sha256": "b3c1343cbeb505639831cd049b497cdfeaeead5acc625450dbb7212bac86cf45", + "version": "0.17.4", "width": 2880 }, "08-agent-tools.png": { "height": 1800, - "sha256": "4447d3c1ff5e12f04fe6d7a9c6a2d0572f160253645e32937e4aa50b1fa9e918", - "version": "0.17.3", + "sha256": "21847d1b756a5b10479e1c9cd62204a3e57068f3063e4e291366aee79627d7ff", + "version": "0.17.4", "width": 2880 }, "09-mcp-servers.png": { "height": 1800, - "sha256": "8822aa24b48a793701caf4ad1378edb2233dfc0ca545a6553fd91bd2f353def3", - "version": "0.17.3", + "sha256": "6b58562f0bf91f461d6e561c406dfd9607812aaa2469da58515ea892ffcef3d3", + "version": "0.17.4", "width": 2880 } } diff --git a/packaging/audits/0.17.7-ui-audit.md b/packaging/audits/0.17.7-ui-audit.md new file mode 100644 index 00000000..cf8814a8 --- /dev/null +++ b/packaging/audits/0.17.7-ui-audit.md @@ -0,0 +1,47 @@ +# Flowlight 0.17.7 UI and accessibility audit + +- Date: November 5, 2026 +- Candidate: Developer edition 0.17.7 (81) +- Minimum window: 1000 × 660 points + +## Result + +No release-blocking UI or accessibility defect was found in the consolidated 0.17.7 candidate. The audit covered the release's changed navigation, Live chart, agent inventory, alert triage, Policies, Sources, Inspect-owned Plugins & Providers, reduced-motion handling, localization, and Store product boundary. + +## Evidence + +| Area | Result | Evidence | +| --- | --- | --- | +| Exact candidate | Pass | Built the Developer product from the release worktree as 0.17.7 (81), launched its exact executable directly, verified each PID and expected window title, and stopped only the same path-verified PID. | +| Demo-data boundary | Pass | Every acceptance launch had `demo.sqlite` open and no `traffic.sqlite` file. The installed production app and production data were not used. | +| Minimum window | Pass | Visually inspected Live, AI Agents, Alerts, Inspect, Policies, and Sources at 1000 × 660. Content remains usable; wide native tables keep their own horizontal scrolling instead of widening the window. | +| Light appearance | Pass | All six primary release surfaces were captured from synthetic demo data and visually inspected. Hierarchy, selection, labels, controls, and status marks remain legible. | +| Dark appearance | Pass | Live was launched and inspected in dark appearance at 1000 × 660. Text, grid lines, table rows, cards, and directional marks remain legible. | +| Increased contrast | Pass | The dark candidate was launched with increased contrast enabled. The executable palette gate also passes text/mark contrast in light and dark appearances. | +| Non-color chart identity | Pass | Live identifies download with a solid line and upload with a dashed line, with explicit legend text. The executable palette gate passes chroma, normal-vision, protan, and deutan separation. | +| Reduced motion | Pass | The candidate launched with reduced motion enabled. Shared `Motion.perform` bypasses animation under reduced motion; disclosure and focused-policy scrolling use that path. Targeted source checks contain no unmanaged direct animation in those changed views. | +| Live keyboard inspection | Pass | The chart is focusable, handles left/right move commands, keeps pointer and keyboard selections independent, and exposes a selected value plus per-second accessibility children. Native tests cover initial movement, bounds, focus without a synthetic tooltip, pointer precedence, restoration of intentional keyboard selection, and retention reconciliation. | +| Live update isolation | Pass | One-second rates and session totals publish through a scoped `TrafficLiveState` instead of invalidating every screen that observes `TrafficMonitor`. Under the same synthetic traffic, settled candidate CPU fell from approximately 43–54% to 12–18%, and the corrected profile contains no `AgentDetail.body` work. The regression test was proved fail-capable by deliberately reconnecting child updates to the parent publisher and observing the expected failure. | +| AI Agents | Pass | The real candidate shows the complete inventory, deterministic health ordering, non-color status icons and labels, explainable reasons, providers, tools, destinations, and allowlist context. Unit tests cover merge rules, health classification, reason ordering, identifier stability, and sensitive-value exclusion. | +| Alerts | Pass | The real candidate shows State and Disposition columns, an Open filter, Triage actions, and the count of new versus open alerts. Database tests cover migrations, valid and invalid transitions, atomic bulk updates, notes, focus, filtering, dismissal, and badge semantics. | +| Policies and Sources | Pass | The candidate shows one Policies/Activity architecture with contextual ownership and one operational Sources page with current collector, extension, and filter state. Empty, unavailable, and remediation copy remains visible where action is required. | +| Plugins & Providers | Pass | Developer Inspect owns the Plugins & Providers toolbar entry and sheet. Provider profiles accept non-secret options and optional Keychain references; validation rejects remote HTTP, endpoint queries, credential-like keys and values, malformed references, and unsupported schemas. The Store compile excludes this surface. | +| Empty/loading/error states | Pass | Changed views provide directed empty states such as quiet traffic and no matching alerts, disable unavailable actions, and present setup/remediation or validation failures in place. Database, provider, plugin, and navigation tests exercise failure paths. | +| Localization | Pass | All ten catalogs contain exactly 1,899/1,899 source keys, with zero missing and zero obsolete keys. Property-list syntax, duplicate keys, placeholder signatures, retained translations, and source order pass. | +| Store boundary | Pass | The unsigned Store product compiles as 0.17.7 (81), embeds only the content-filter app extension, and contains no Developer system extension, response-script helper, privileged installer, SystemExtensions framework linkage, or `OSSystemExtension` symbol. Inspect and Plugins & Providers remain Developer-only until 0.18.0. | + +## Automated gates + +- Complete macOS suite: 1,218 tests, zero failures. +- Swift warning gate: no first-party warnings. +- Performance budgets: startup, 600-edge graph, 10,001-row History query, 10,000-alert query, 1,000-agent inventory, and 5,000 × 6 plugin evaluation all pass; the gate's 101 ms / 100 ms fixture proves rejection. +- Chart palette: executable light/dark contrast, chroma, normal-vision, protan, and deutan checks pass; adversarial fixtures pass. +- Localization: source extraction, strict parity, placeholders, ordering, and `plutil` validation pass. +- Developer and unsigned Store product graphs compile independently. + +## Limits and deferred work + +- System Events did not expose the candidate window to this shell, so no manual AX click script is claimed. Keyboard, focus, labels, values, and ordering were instead checked through source inspection and the passing native suites. +- Signed Store entitlements were not exercised locally. The product was not re-signed; signed entitlement validation remains an authorized archive/CI gate. +- Temporary acceptance images were synthetic demo evidence only. They are not website or App Store assets and are not committed. +- Store screenshots, metadata, upload, build selection, and App Review submission remain deferred to 0.18.0. diff --git a/packaging/release-notes/0.17.4.md b/packaging/release-notes/0.17.4.md new file mode 100644 index 00000000..3f31632b --- /dev/null +++ b/packaging/release-notes/0.17.4.md @@ -0,0 +1,19 @@ +### What's new in 0.17.4 + +- **See every control in Policies.** Rules is now a unified Policies inventory for ordinary rules, agent allowlists, managed packs, and Developer-only inspection policies, with truthful states such as enforcing, reporting, watching, waiting, scheduled, paused, applying, review required, or unavailable. +- **Keep each policy's real editor and evidence.** Selecting an item opens the contextual editor that owns it; Managed packs retain signer, publisher, catalog, version, and license provenance without weakening review or consent, while Activity combines existing records without pretending they share one persistence model. +- **Read Sources as one operational page.** Collector, Devices, and Coverage now flow in that order without a segmented control. Existing status links, Ask answers, restored windows, and screenshot arguments scroll to and briefly focus the requested section. +- **Dismiss chart details when the pointer leaves.** History no longer leaves a zero-value tooltip floating at the upper-left after the cursor exits the All traffic plot; keyboard bucket inspection remains unchanged. +- **Sign each release once.** The immutable tag run builds, signs, notarizes, Gatekeeper-verifies, attests, and retains one artifact; publication has no signing credentials and can only verify and publish those exact prepared bytes. + +### Install + +Signed with Developer ID and notarized by Apple. + +- `brew install --cask xinbetween/tap/flowlight` +- `Flowlight.dmg` — drag to Applications. +- `Flowlight-0.17.4.pkg` — installs to /Applications; offers to quit a running copy first. + +macOS 15 or later, Apple silicon and Intel. + +Full changelog: https://github.com/xinbetween/flowlight/releases diff --git a/packaging/release-notes/0.17.5.md b/packaging/release-notes/0.17.5.md new file mode 100644 index 00000000..f3880841 --- /dev/null +++ b/packaging/release-notes/0.17.5.md @@ -0,0 +1,24 @@ +### What's new in 0.17.5 + +- **Account for every AI agent.** Agents combines installed apps, configured workspaces, known launchers, and observed traffic into one inventory. Health is explained through concrete evidence, routing, inspection, policy coverage, and unresolved alerts instead of an opaque score. +- **Take alerts from new to resolved.** Alerts supports new, acknowledged, investigating, resolved, and dismissed states, plus dispositions, notes, and bulk actions. Triage remains separate from policy Allow actions, and the sidebar badge counts only new alerts. +- **Manage inspection extensions where they work.** The Developer edition moves plugin packages into Inspect › Plugins & Providers and adds versioned local provider profiles with compatible plugin assignment, validated endpoints, non-secret options, and optional Keychain references. Raw credentials are never stored in a profile. +- **Inspect Live traffic without relying on color or a pointer.** The Live chart adds keyboard traversal, VoiceOver details for each second, strict plot hit testing, and distinct solid/circle and dashed/diamond series. Download and upload colors now pass executable light/dark contrast, chroma, and color-vision checks. +- **Keep redesigned paths within measured limits.** Fail-capable numeric budgets now cover startup, a 600-edge graph, a 10,001-row History query, a 10,000-alert query, a 1,000-agent inventory, and 30,000 built-in plugin evaluations, with fixture size, observed time, and budget in every failure. +- **Carry the completed 0.17.4 work forward.** The unpublished 0.17.4 changes ship here: Policies preserves every policy type's authoritative editor and evidence, Sources is one Collector–Devices–Coverage document, and History clears pointer details without synthesizing a keyboard selection. +- **Reuse CI only when the evidence is exact.** Pull-request app jobs may reuse a successful canonical run only when its complete Git tree, app-input fingerprint, required conclusions, receipt schema, head repository, run identity, artifact digest, and expiry match. Invalid, stale, or forked evidence runs the current jobs instead of silently succeeding. +- **Deploy only the site CI verified.** Pages consumes the exact manifested site artifact from successful canonical `main` CI, performs no checkout or rebuild, and rechecks the current `main` SHA immediately before deployment. Weekly full Swift, Python, and Actions CodeQL remains in place. +- **Keep untrusted pull-request refs out of checkouts.** Merge compatibility uses GitHub's trusted `pull_request` event merge commit rather than constructing a checkout ref from pull-request input, resolving code-scanning alert 4 without moving the job to a privileged event. +- **Sign each release once.** The immutable tag run builds, signs, notarizes, Gatekeeper-verifies, attests, and retains one artifact. Credential-free publication can only verify and publish those exact prepared bytes, and authenticated review evidence is fetched directly for each exact-head pull request with bounded retries. + +### Install + +Signed with Developer ID and notarized by Apple. + +- `brew install --cask xinbetween/tap/flowlight` +- `Flowlight.dmg` — drag to Applications. +- `Flowlight-0.17.5.pkg` — installs to /Applications; offers to quit a running copy first. + +macOS 15 or later, Apple silicon and Intel. + +Full changelog: https://github.com/xinbetween/flowlight/releases diff --git a/packaging/release-notes/0.17.7.md b/packaging/release-notes/0.17.7.md new file mode 100644 index 00000000..5d519048 --- /dev/null +++ b/packaging/release-notes/0.17.7.md @@ -0,0 +1,24 @@ +### What's new in 0.17.7 + +- **Account for every AI agent.** Agents combines installed apps, configured workspaces, known launchers, and observed traffic into one inventory. Health is explained through concrete evidence, routing, inspection, policy coverage, and unresolved alerts instead of an opaque score. +- **Take alerts from new to resolved.** Alerts supports new, acknowledged, investigating, resolved, and dismissed states, plus dispositions, notes, and bulk actions. Triage remains separate from policy Allow actions, and the sidebar badge counts only new alerts. +- **Manage inspection extensions where they work.** The Developer edition moves plugin packages into Inspect › Plugins & Providers and adds versioned local provider profiles with compatible plugin assignment, validated endpoints, non-secret options, and optional Keychain references. Raw credentials are never stored in a profile. +- **Inspect Live traffic without relying on color or a pointer.** The Live chart adds keyboard traversal, VoiceOver details for each second, strict plot hit testing, and distinct solid/circle and dashed/diamond series. Download and upload colors now pass executable light/dark contrast, chroma, and color-vision checks. +- **Keep redesigned paths within measured limits.** Fail-capable numeric budgets cover startup, a 600-edge graph, a 10,001-row History query, a 10,000-alert query, a 1,000-agent inventory, and 30,000 built-in plugin evaluations, with fixture size, observed time, and budget in every failure. +- **Carry the completed unpublished work forward.** The unpublished 0.17.4, 0.17.5, and 0.17.6 changes ship here: Policies preserves every policy type's authoritative editor and evidence, Sources is one Collector–Devices–Coverage document, History clears pointer details without synthesizing a keyboard selection, and release review receipts remain valid when GitHub hides a trusted reviewer's private organization membership from the workflow token. +- **Reuse CI only when the evidence is exact.** Pull-request app jobs may reuse a successful canonical run only when its complete Git tree, app-input fingerprint, required conclusions, receipt schema, head repository, run identity, artifact digest, and expiry match. Invalid, stale, or forked evidence runs the current jobs instead of silently succeeding. +- **Deploy only the site CI verified.** Pages consumes the exact manifested site artifact from successful canonical `main` CI, performs no checkout or rebuild, and rechecks the current `main` SHA immediately before deployment. Weekly full Swift, Python, and Actions CodeQL remains in place. +- **Keep untrusted pull-request refs out of checkouts.** Merge compatibility uses GitHub's trusted `pull_request` event merge commit rather than constructing a checkout ref from pull-request input, resolving code-scanning alert 4 without moving the job to a privileged event. +- **Sign each release once.** The immutable tag run builds, signs, notarizes, Gatekeeper-verifies, attests, and retains one artifact. Credential-free publication can only verify and publish those exact prepared bytes, authenticated review evidence is fetched directly for each exact-head pull request, and an accepted Apple ticket can survive a bounded multi-minute CloudKit delivery outage without resubmission. + +### Install + +Signed with Developer ID and notarized by Apple. + +- `brew install --cask xinbetween/tap/flowlight` +- `Flowlight.dmg` — drag to Applications. +- `Flowlight-0.17.7.pkg` — installs to /Applications; offers to quit a running copy first. + +macOS 15 or later, Apple silicon and Intel. + +Full changelog: https://github.com/xinbetween/flowlight/releases diff --git a/packaging/release-notes/README.md b/packaging/release-notes/README.md index 95fc0380..623bf7b3 100644 --- a/packaging/release-notes/README.md +++ b/packaging/release-notes/README.md @@ -2,6 +2,7 @@ One file per version, named `.md` — `0.2.2.md` for the tag `v0.2.2`. The release workflow uses it as the body of the GitHub release and appends the checksums, which it only knows once the artifacts are built. Without a -file for the version, the release falls back to GitHub's generated notes. +file for the version, preparation extracts the matching entry from the generated releases page; if neither source +exists, it fails rather than publishing generic notes from a different checkout. The same text usually belongs in `site/pages/releases.html`, which is what the website shows. diff --git a/project.yml b/project.yml index 53928371..d2874f72 100644 --- a/project.yml +++ b/project.yml @@ -15,8 +15,8 @@ settings: DEVELOPMENT_TEAM: "" CODE_SIGN_STYLE: Automatic ENABLE_HARDENED_RUNTIME: YES - MARKETING_VERSION: "0.17.3" - CURRENT_PROJECT_VERSION: "77" + MARKETING_VERSION: "0.17.7" + CURRENT_PROJECT_VERSION: "81" configs: AppStore: DEVELOPMENT_TEAM: 38RJUJHKZS diff --git a/scripts/check_chart_palette.py b/scripts/check_chart_palette.py new file mode 100644 index 00000000..034d71b4 --- /dev/null +++ b/scripts/check_chart_palette.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Validate Flowlight's executable light/dark chart palette from DesignTokens.swift.""" + +from __future__ import annotations + +import argparse +import itertools +import math +import pathlib +import re +import sys + +TOKEN_PATTERN = re.compile( + r"static\s+let\s+(\w+)\s*=\s*Color\(light:\s*0x([0-9a-fA-F]{6}),\s*dark:\s*0x([0-9a-fA-F]{6})\)" +) +REQUIRED = { + "ground", "surface", "ink", "muted", "received", "sent", + "graphReceived", "graphSent", "graphInvocation", +} +MACHADO = { + "protan": ( + (0.152286, 1.052583, -0.204868), + (0.114503, 0.786281, 0.099216), + (-0.003882, -0.048116, 1.051998), + ), + "deutan": ( + (0.367322, 0.860646, -0.227968), + (0.280085, 0.672501, 0.047413), + (-0.011820, 0.042940, 0.968881), + ), +} + + +def parse_tokens(source: str) -> dict[str, dict[str, str]]: + return { + name: {"light": light.lower(), "dark": dark.lower()} + for name, light, dark in TOKEN_PATTERN.findall(source) + } + + +def srgb_channel(value: int) -> float: + channel = value / 255.0 + return channel / 12.92 if channel <= 0.04045 else ((channel + 0.055) / 1.055) ** 2.4 + + +def linear_rgb(value: str) -> tuple[float, float, float]: + return tuple(srgb_channel(int(value[index:index + 2], 16)) for index in (0, 2, 4)) + + +def oklab(rgb: tuple[float, float, float]) -> tuple[float, float, float]: + red, green, blue = rgb + l_value = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue + m_value = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue + s_value = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue + l_root = math.copysign(abs(l_value) ** (1 / 3), l_value) + m_root = math.copysign(abs(m_value) ** (1 / 3), m_value) + s_root = math.copysign(abs(s_value) ** (1 / 3), s_value) + return ( + 0.2104542553 * l_root + 0.7936177850 * m_root - 0.0040720468 * s_root, + 1.9779984951 * l_root - 2.4285922050 * m_root + 0.4505937099 * s_root, + 0.0259040371 * l_root + 0.7827717662 * m_root - 0.8086757660 * s_root, + ) + + +def simulate(rgb: tuple[float, float, float], matrix: tuple[tuple[float, ...], ...]) -> tuple[float, float, float]: + return tuple(max(0.0, min(1.0, sum(row[index] * rgb[index] for index in range(3)))) for row in matrix) + + +def delta(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: + return math.sqrt(sum((left[index] - right[index]) ** 2 for index in range(3))) * 100 + + +def contrast(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: + def luminance(rgb: tuple[float, float, float]) -> float: + return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] + + light, dark = sorted((luminance(left), luminance(right)), reverse=True) + return (light + 0.05) / (dark + 0.05) + + +def validate(tokens: dict[str, dict[str, str]]) -> list[str]: + missing = sorted(REQUIRED - tokens.keys()) + if missing: + return [f"missing semantic token: {name}" for name in missing] + + issues: list[str] = [] + palettes = { + "directional": ("received", "sent"), + "graph": ("graphReceived", "graphSent", "graphInvocation"), + } + for mode in ("light", "dark"): + surface = linear_rgb(tokens["surface"][mode]) + band = (0.43, 0.77) if mode == "light" else (0.48, 0.67) + for palette_name, names in palettes.items(): + values = {name: linear_rgb(tokens[name][mode]) for name in names} + for name, rgb in values.items(): + lightness, a_value, b_value = oklab(rgb) + chroma = math.sqrt(a_value * a_value + b_value * b_value) + if not band[0] <= lightness <= band[1]: + issues.append(f"{mode} {name} lightness {lightness:.3f} outside {band[0]:.2f}-{band[1]:.2f}") + if chroma < 0.10: + issues.append(f"{mode} {name} chroma {chroma:.3f} below 0.10") + mark_contrast = contrast(rgb, surface) + if mark_contrast < 3.0: + issues.append(f"{mode} {name} contrast {mark_contrast:.2f}:1 below 3:1") + + for left_name, right_name in itertools.combinations(names, 2): + left, right = values[left_name], values[right_name] + normal = delta(oklab(left), oklab(right)) + if normal < 15: + issues.append(f"{mode} {palette_name} {left_name}/{right_name} normal Delta E {normal:.1f} below 15") + for simulation, matrix in MACHADO.items(): + separation = delta(oklab(simulate(left, matrix)), oklab(simulate(right, matrix))) + if separation < 8: + issues.append( + f"{mode} {palette_name} {left_name}/{right_name} {simulation} Delta E {separation:.1f} below 8" + ) + + for text_name in ("ink", "muted"): + for surface_name in ("ground", "surface"): + ratio = contrast(linear_rgb(tokens[text_name][mode]), linear_rgb(tokens[surface_name][mode])) + if ratio < 4.5: + issues.append(f"{mode} {text_name} on {surface_name} contrast {ratio:.2f}:1 below 4.5:1") + return issues + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--source", + type=pathlib.Path, + default=pathlib.Path(__file__).resolve().parents[1] / "Flowlight/UI/DesignTokens.swift", + ) + args = parser.parse_args() + tokens = parse_tokens(args.source.read_text(encoding="utf-8")) + issues = validate(tokens) + if issues: + for issue in issues: + print(f"palette rejected: {issue}", file=sys.stderr) + return 1 + print("Flowlight chart palette passes light/dark contrast, chroma, normal-vision, protan, and deutan checks.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_release_checks.py b/scripts/check_release_checks.py index 9eca91a0..70560019 100644 --- a/scripts/check_release_checks.py +++ b/scripts/check_release_checks.py @@ -4,6 +4,7 @@ import argparse import json import pathlib +import re import sys @@ -16,6 +17,14 @@ ("github-actions", "CodeQL / gate"), ("github-advanced-security", "CodeQL"), ) +EXPECTED_ACTIONS_WORKFLOWS = { + ("github-actions", "changes"): ".github/workflows/ci.yml", + ("github-actions", "site"): ".github/workflows/ci.yml", + ("github-actions", "unit (oldest Xcode)"): ".github/workflows/ci.yml", + ("github-actions", "unit (newest Xcode)"): ".github/workflows/ci.yml", + ("github-actions", "Review / gate"): ".github/workflows/ci.yml", + ("github-actions", "CodeQL / gate"): ".github/workflows/codeql.yml", +} def load(path): @@ -32,6 +41,16 @@ def check_runs(document): return runs +def workflow_runs(document): + pages = document if isinstance(document, list) else [document] + runs = [] + for page in pages: + if not isinstance(page, dict) or not isinstance(page.get("workflow_runs"), list): + raise ValueError("workflow-run response has an unexpected shape") + runs.extend(page["workflow_runs"]) + return runs + + def pull_requests(document): pages = document if isinstance(document, list) else [document] pulls = [] @@ -45,47 +64,151 @@ def pull_requests(document): return pulls -def verify(repo, sha, checks_document, pulls_document): +def issue_comments(document): + pages = document if isinstance(document, list) else [document] + comments = [] + for page in pages: + if not isinstance(page, list): + raise ValueError("issue-comment response has an unexpected shape") + comments.extend(page) + return comments + + +def select_checks(repo, sha, checks_document, runs_document, reviewed_pull_numbers=None): + actions_runs = {int(run.get("id", 0)): run for run in workflow_runs(runs_document)} + latest = {} + provenance = {} + repo_prefix = f"https://github.com/{repo}/" + run_url = re.compile(rf"^https://github\.com/{re.escape(repo)}/actions/runs/([0-9]+)(?:/.*)?$") + for check in check_runs(checks_document): + key = (check.get("app", {}).get("slug"), check.get("name")) + if key not in REQUIRED_CHECKS: + continue + details_url = check.get("details_url", "") + if not details_url.startswith(repo_prefix): + continue + workflow = None + if key in EXPECTED_ACTIONS_WORKFLOWS: + match = run_url.fullmatch(details_url) + if not match: + continue + workflow = actions_runs.get(int(match.group(1))) + if not workflow: + continue + if workflow.get("path") != EXPECTED_ACTIONS_WORKFLOWS[key]: + continue + if workflow.get("head_sha") != sha or workflow.get("event") != "pull_request": + continue + if workflow.get("repository", {}).get("full_name") != repo: + continue + if reviewed_pull_numbers is not None: + linked_pulls = workflow.get("pull_requests") + linked_pull = next(( + pull for pull in linked_pulls + if isinstance(pull, dict) + and pull.get("number") in reviewed_pull_numbers + and pull.get("head", {}).get("sha") == sha + and pull.get("base", {}).get("ref") == "main" + ), None) if isinstance(linked_pulls, list) else None + if linked_pull is None: + continue + workflow = dict(workflow) + workflow["_reviewed_pull_number"] = int(linked_pull["number"]) + if int(check.get("id", 0)) > int(latest.get(key, {}).get("id", 0)): + latest[key] = check + provenance[key] = workflow + return latest, provenance + + +def verify(repo, sha, checks_document, pulls_document, runs_document, comments_document, trusted_reviewers): errors = [] - eligible_pulls = [] + associated_pulls = [] + review_marker = f"Reviewed-Commit: {sha}" + trusted_reviewers = {value.lower() for value in re.split(r"[\s,]+", trusted_reviewers) if value} + if not trusted_reviewers: + errors.append("no trusted release reviewers are configured") for pull in pull_requests(pulls_document): head_sha = pull.get("head", {}).get("sha") base_ref = pull.get("base", {}).get("ref") state = pull.get("state") merged = bool(pull.get("merged_at")) if head_sha == sha and base_ref == "main" and (state == "open" or merged): - eligible_pulls.append(pull) - if not eligible_pulls: - errors.append(f"commit {sha} has no open or merged pull request targeting main at that exact head") + associated_pulls.append(pull) + if not associated_pulls: + errors.append("exact source commit has no open or merged pull request targeting main at that exact head") - latest = {} - repo_prefix = f"https://github.com/{repo}/" - for run in check_runs(checks_document): - app = run.get("app", {}).get("slug") - name = run.get("name") - if (app, name) not in REQUIRED_CHECKS: + associated_numbers = {int(pull["number"]) for pull in associated_pulls} + receipts = [] + issue_url = re.compile(rf"^https://api\.github\.com/repos/{re.escape(repo)}/issues/([0-9]+)$") + for comment in issue_comments(comments_document): + if not isinstance(comment, dict): continue - details_url = run.get("details_url", "") - if not details_url.startswith(repo_prefix): + match = issue_url.fullmatch(comment.get("issue_url", "")) + if not match or int(match.group(1)) not in associated_numbers: + continue + pull_number = int(match.group(1)) + body = comment.get("body") + body_lines = {line.strip() for line in body.splitlines()} if isinstance(body, str) else set() + user = comment.get("user") + reviewer = user.get("login") if isinstance(user, dict) else None + if review_marker not in body_lines or not isinstance(reviewer, str) or reviewer.lower() not in trusted_reviewers: continue - key = (app, name) - if int(run.get("id", 0)) > int(latest.get(key, {}).get("id", 0)): - latest[key] = run + # `author_association` is viewer-dependent when organization membership is + # private: the release GITHUB_TOKEN can report CONTRIBUTOR for the same + # comment an organization member sees as MEMBER. The configured reviewer + # allowlist is the authorization boundary; GitHub's comment identity, URL, + # numeric ID, exact PR, and exact commit marker authenticate the receipt. + association = comment.get("author_association") + comment_id = comment.get("id") + html_url = comment.get("html_url") + if not isinstance(association, str) or not association: + continue + if not isinstance(comment_id, int) or isinstance(comment_id, bool) or comment_id <= 0: + continue + expected_url = f"https://github.com/{repo}/pull/{pull_number}#issuecomment-{comment_id}" + if html_url != expected_url: + continue + receipts.append({ + "pull_request": pull_number, + "comment_id": comment_id, + "reviewer": reviewer, + "author_association": association, + "reviewed_commit": sha, + "url": html_url, + }) + receipts.sort(key=lambda receipt: receipt["comment_id"]) + if len({receipt["comment_id"] for receipt in receipts}) != len(receipts): + errors.append("authenticated review comments contain duplicate comment IDs") + if associated_pulls and not receipts: + errors.append( + "no authenticated exact-head review comment from a configured trusted reviewer" + ) + reviewed_pull_numbers = {receipt["pull_request"] for receipt in receipts} + latest, provenance = select_checks( + repo, sha, checks_document, runs_document, reviewed_pull_numbers + ) for key in REQUIRED_CHECKS: - run = latest.get(key) + check = latest.get(key) label = f"{key[1]} ({key[0]})" - if not run: - errors.append(f"missing required check: {label}") - elif run.get("status") != "completed" or run.get("conclusion") != "success": + if not check: + errors.append(f"missing required check from its trusted workflow: {label}") + continue + if check.get("status") != "completed" or check.get("conclusion") != "success": errors.append( f"required check is not successful: {label} " - f"status={run.get('status')} conclusion={run.get('conclusion')}" + f"status={check.get('status')} conclusion={check.get('conclusion')}" + ) + workflow = provenance.get(key) + if workflow and (workflow.get("status") != "completed" or workflow.get("conclusion") != "success"): + errors.append( + f"trusted workflow run is not successful: {label} " + f"status={workflow.get('status')} conclusion={workflow.get('conclusion')}" ) if errors: - return errors, [] - return [], [int(pull["number"]) for pull in eligible_pulls] + return errors, [], [] + return [], sorted(associated_numbers), receipts def main(): @@ -94,10 +217,25 @@ def main(): parser.add_argument("--sha", required=True) parser.add_argument("--checks", type=pathlib.Path, required=True) parser.add_argument("--pulls", type=pathlib.Path, required=True) + parser.add_argument("--runs", type=pathlib.Path, required=True) + parser.add_argument("--comments", type=pathlib.Path, required=True) + parser.add_argument("--reviewers", required=True, + help="comma- or whitespace-separated trusted GitHub reviewer logins") + parser.add_argument("--evidence-output", type=pathlib.Path) args = parser.parse_args() try: - errors, pull_numbers = verify(args.repo, args.sha, load(args.checks), load(args.pulls)) + checks_document = load(args.checks) + runs_document = load(args.runs) + errors, pull_numbers, review_receipts = verify( + args.repo, + args.sha, + checks_document, + load(args.pulls), + runs_document, + load(args.comments), + args.reviewers, + ) except (OSError, ValueError, json.JSONDecodeError) as error: print(f"release check evidence could not be read: {error}", file=sys.stderr) return 1 @@ -107,8 +245,40 @@ def main(): print(f"release check rejected: {error}", file=sys.stderr) return 1 + if args.evidence_output: + latest, provenance = select_checks( + args.repo, + args.sha, + checks_document, + runs_document, + {receipt["pull_request"] for receipt in review_receipts}, + ) + evidence = { + "repository": args.repo, + "source_sha": args.sha, + "pull_requests": pull_numbers, + "review_receipts": review_receipts, + "checks": [ + { + "app": app, + "name": name, + "id": int(latest[(app, name)]["id"]), + "details_url": latest[(app, name)]["details_url"], + "workflow": provenance[(app, name)]["path"] if provenance.get((app, name)) else None, + "workflow_run_id": int(provenance[(app, name)]["id"]) if provenance.get((app, name)) else None, + "pull_request": provenance[(app, name)]["_reviewed_pull_number"] if provenance.get((app, name)) else None, + } + for app, name in REQUIRED_CHECKS + ], + } + args.evidence_output.write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n") + joined = ", ".join(f"#{number}" for number in pull_numbers) - print(f"Exact commit {args.sha} has reviewed release evidence from {joined} and all required checks passed.") + reviewers = ", ".join(sorted({receipt["reviewer"] for receipt in review_receipts})) + print( + f"Exact commit {args.sha} has authenticated review evidence from {reviewers} " + f"on {joined} and all required checks passed." + ) return 0 diff --git a/scripts/check_site.py b/scripts/check_site.py index 2c2ac502..79e1dbc1 100644 --- a/scripts/check_site.py +++ b/scripts/check_site.py @@ -93,6 +93,62 @@ def missing_shortcuts(cell, required): return [label for label in required if label not in text] +def capture_version_problems(entries, current_version, label): + """Keep screenshot provenance truthful without forcing unrelated releases to relabel old image bytes.""" + versions = [] + for name, entry in entries.items(): + version = entry.get("version") if isinstance(entry, dict) else None + if not isinstance(version, str): + return [f"{label}: {name} has no capture version"] + try: + parsed = version_key(version) + except (TypeError, ValueError): + return [f"{label}: {name} has invalid capture version {version!r}"] + versions.append((version, parsed)) + found = [] + future = sorted({version for version, parsed in versions if parsed > version_key(current_version)}, key=version_key) + if future: + found.append(f"{label}: capture version is newer than app {current_version}: {', '.join(future)}") + return found + + +def pages_workflow_problems(source): + """Pages must deploy only the exact site artifact already verified by canonical main CI.""" + found = [] + trigger = source.split("\npermissions:", 1)[0] + if not all(marker in trigger for marker in ( + "workflow_run:", "workflows: [CI]", "branches: [main]", "types: [completed]", + )): + found.append("Pages is not restricted to completed CI runs for main") + if "workflow_dispatch:" in trigger or re.search(r"(?m)^ (push|pull_request):", trigger): + found.append("Pages has a source-bypassing trigger") + if "actions/checkout@" in source or "scripts/build_site.py" in source: + found.append("Pages checks out or rebuilds source") + required_provenance = ( + "github.repository == 'xinbetween/flowlight'", + "github.event.workflow_run.repository.full_name == 'xinbetween/flowlight'", + "github.event.workflow_run.head_repository.full_name == 'xinbetween/flowlight'", + "github.event.workflow_run.path == '.github/workflows/ci.yml'", + "github.event.workflow_run.event == 'push'", + "github.event.workflow_run.head_branch == 'main'", + "github.event.workflow_run.status == 'completed'", + "github.event.workflow_run.conclusion == 'success'", + ) + if not all(marker in source for marker in required_provenance): + found.append("Pages does not bind its source to successful canonical main CI") + if "consume-pages" not in source or "actions/upload-pages-artifact@" not in source: + found.append("Pages does not consume and upload the verified CI artifact") + if source.count('CURRENT_MAIN=$(gh api "repos/$REPOSITORY/git/ref/heads/main"') != 1 \ + or source.count('CURRENT_MAIN=$(curl --fail --silent --show-error') != 1: + found.append("Pages does not reject stale main before verification and deployment") + deploy = source.split("\n deploy:\n", 1)[1] if "\n deploy:\n" in source else "" + permission_block = re.search(r"(?ms)^ permissions:\n((?: [^\n]+\n)+)", deploy) + permissions = permission_block.group(1) if permission_block else "" + if permissions.strip().splitlines() != ["pages: write", " id-token: write"]: + found.append("Pages deploy permissions are not limited to pages and id-token write") + return found + + def problems(): found = [] @@ -283,7 +339,7 @@ def entries(text): f"button opens it — a new screen the documentation never got") # 12. A shortcut the documentation gets wrong. Shortcuts follow each product's visible sidebar. Developer ID - # includes Inspect; the Store product does not, so Rules and Sources move up by one there. + # includes Inspect; the Store product does not, so Policies and Sources move up by one there. order = sidebar_order(nav) requirements = shortcut_requirements(order) titles = dict(HELP_ANCHOR.findall(nav[nav.find("var englishTitle"):nav.find("var title")])) @@ -300,24 +356,24 @@ def entries(text): f"but the sidebar puts it at \u2318{key}") docs_pages = [OUT / "docs/index.html"] + [OUT / lang / "docs/index.html" for lang in LANGS] - shortcut_anchors = { - "inspect": ("inspection",), - "rules": ("rules",), - "sources": ("capture", "sources"), + shortcut_rows = { + "inspect": ("inspect", ("inspection",)), + "policies": ("rules", ("policies",)), + "sources": ("sources", ("capture", "sources")), } for docs_page in docs_pages: page_text = docs_page.read_text() - for screen, anchors in shortcut_anchors.items(): + for label_name, (screen, anchors) in shortcut_rows.items(): anchor_pattern = "|".join(re.escape(anchor) for anchor in anchors) cell_match = re.search( rf'', page_text, re.S) label = str(docs_page.relative_to(ROOT)) if cell_match is None: - found.append(f"{label}: the views table has no {screen} shortcut row") + found.append(f"{label}: the views table has no {label_name} shortcut row") continue for missing in missing_shortcuts(cell_match.group(1), requirements[screen]): - found.append(f"{label}: the {screen} row never lists {missing}") + found.append(f"{label}: the {label_name} row never lists {missing}") # 14. The homepage repeats the same shortcut badges and a compact Sources promise. Keep Collector, Devices, # and Coverage together, and keep both product-specific shortcut maps truthful. @@ -380,8 +436,9 @@ def entries(text): if shot.name not in referenced: found.append(f"{shot.relative_to(ROOT)}: no page shows this screenshot") - # 11. Every screenshot records the app version, exact bytes and dimensions from its own capture. A selective - # refresh must not bless untouched images: each entry advances only when that image is actually recaptured. + # 11. Every screenshot records the exact app version, bytes, and dimensions from its own capture. Screenshot + # provenance does not advance merely because the app version does: unrelated releases may retain an older + # truthful set, and a selective refresh must never bless untouched image bytes as newly captured. manifest_path = shots / "manifest.json" try: manifest = json.loads(manifest_path.read_text()) @@ -396,14 +453,13 @@ def entries(text): missing = sorted(names - set(entries)) extra = sorted(set(entries) - names) found.append(f"{manifest_path.relative_to(ROOT)}: screenshot entries differ; missing={missing}, extra={extra}") + found.extend(capture_version_problems(entries, APP_VERSION, str(manifest_path.relative_to(ROOT)))) for shot in screenshot_files: entry = entries.get(shot.name) if not isinstance(entry, dict): continue raw = shot.read_bytes() real = (int.from_bytes(raw[16:20], "big"), int.from_bytes(raw[20:24], "big")) - if entry.get("version") != APP_VERSION: - found.append(f"{shot.relative_to(ROOT)}: captured for {entry.get('version')}, app is {APP_VERSION}") if entry.get("sha256") != hashlib.sha256(raw).hexdigest(): found.append(f"{shot.relative_to(ROOT)}: bytes differ from the screenshot manifest") if (entry.get("width"), entry.get("height")) != real: @@ -435,14 +491,13 @@ def entries(text): found.append(f"{store_manifest_path.relative_to(ROOT)}: Store manifest entries differ; " f"missing={sorted(expected_store_names - set(store_entries))}, " f"extra={sorted(set(store_entries) - expected_store_names)}") + found.extend(capture_version_problems(store_entries, APP_VERSION, str(store_manifest_path.relative_to(ROOT)))) for shot in store_files: entry = store_entries.get(shot.name) if not isinstance(entry, dict): continue raw = shot.read_bytes() real = (int.from_bytes(raw[16:20], "big"), int.from_bytes(raw[20:24], "big")) - if entry.get("version") != APP_VERSION: - found.append(f"{shot.relative_to(ROOT)}: captured for {entry.get('version')}, app is {APP_VERSION}") if entry.get("sha256") != hashlib.sha256(raw).hexdigest(): found.append(f"{shot.relative_to(ROOT)}: bytes differ from the screenshot manifest") if (entry.get("width"), entry.get("height")) != real: @@ -570,36 +625,11 @@ def entries(text): if ".install-choices" not in site_css or ".github-link" not in site_css: found.append("site/site.css: installation-choice or secondary GitHub styling is missing") - # 16. Pages occasionally needs redeploying without changing public bytes. A manual dispatch has no `before` - # SHA, so it must compare with the signed catalog already being served rather than a guessed commit. Keep these - # contracts scoped to the trigger, deploy job, and catalog-verification step so duplicate text elsewhere cannot - # make a broken workflow look safe. - pages_workflow = PAGES_WORKFLOW.read_text() - trigger = pages_workflow.split("\npermissions:", 1)[0] - deploy = pages_workflow.split("\n deploy:\n", 1)[1] if "\n deploy:\n" in pages_workflow else "" - catalog_step = deploy.split(" - name: Verify the official rule-pack catalog\n", 1)[1] \ - if " - name: Verify the official rule-pack catalog\n" in deploy else "" - catalog_step = catalog_step.split("\n - ", 1)[0] - if not re.search(r"(?m)^ workflow_dispatch:\s*$", trigger): - found.append(".github/workflows/pages.yml: manual Pages redeployment is unavailable") - main_only = r"(?m)^ if: \$\{\{ github\.event_name == 'push' \|\| github\.ref == 'refs/heads/main' \}\}\s*$" - if not re.search(main_only, deploy): - found.append(".github/workflows/pages.yml: manual deployment is not restricted to main") - gh_token = r"(?m)^ GH_TOKEN: \$\{\{ github\.token \}\}\s*$" - live_url = r"(?m)^ LIVE_CATALOG_URL: https://flowlight\.xinbetween\.com/packs/v1/official\.json\s*$" - deployment_lookup = r"(?s)if DEPLOYMENT_IDS=\$\(gh api .*?/deployments\?environment=github-pages&per_page=20'.*?DEPLOYMENT_STATE=\$\(gh api .*?/statuses.*?continue" - deployed_bytes = r"(?s)DEPLOYED_REF=\$\(gh api .*?application/vnd\.github\.raw\+json.*?/contents/site/packs/v1/official\.json\?ref=\$DEPLOYED_REF.*?> \"\$PREVIOUS_CATALOG\".*?DEPLOYED_CATALOG_FOUND=true" - live_fallback = r"(?s)if \[ \"\$DEPLOYED_CATALOG_FOUND\" != \"true\" \].*?curl .*?--output \"\$PREVIOUS_CATALOG\" \"\$LIVE_CATALOG_URL\"" - baseline_verify = r"(?s)rule_pack_catalog\.py verify \"\$PREVIOUS_CATALOG\" .*?--expected-key-id flowlight-rule-packs-2026-01" - baseline_compare = r"(?s)compare-previous site/packs/v1/official\.json \\\n\s+--previous \"\$PREVIOUS_CATALOG\"" - if (not re.search(gh_token, catalog_step) - or not re.search(live_url, catalog_step) - or not re.search(deployment_lookup, catalog_step) - or not re.search(deployed_bytes, catalog_step) - or not re.search(live_fallback, catalog_step) - or not re.search(baseline_verify, catalog_step) - or not re.search(baseline_compare, catalog_step)): - found.append(".github/workflows/pages.yml: manual deployment does not verify the recorded or live catalog baseline") + # 16. Pages deploys no source checkout and performs no rebuild. Only the exact manifested site artifact from a + # successful canonical main CI run may reach the deploy job, and the source SHA is checked again immediately + # before deployment so a queued stale run cannot publish after main advances. + for issue in pages_workflow_problems(PAGES_WORKFLOW.read_text()): + found.append(f".github/workflows/pages.yml: {issue}") return found diff --git a/scripts/ci-secrets.sh b/scripts/ci-secrets.sh index 8f05949c..105e3f92 100755 --- a/scripts/ci-secrets.sh +++ b/scripts/ci-secrets.sh @@ -1,14 +1,16 @@ #!/bin/zsh -# Puts everything .github/workflows/release.yml needs into GitHub Actions secrets. +# Puts the signing inputs .github/workflows/release.yml's preparation job needs into GitHub Actions secrets. # # scripts/ci-secrets.sh # # It reads the two .p12 certificate exports, the two provisioning profiles and the App Store Connect key you point # it at, and uploads each with `gh secret set`. File contents are piped straight to gh and never printed. # -# They go into the `release` environment, not the repository, so only the release job can read them and only after -# a human approves the run. Even so: anyone who can approve a release can sign software as you, and anyone with -# admin access can change who that is. Revoke the identities in Apple's developer portal if that stops being true. +# They go into the `release` preparation environment, not the repository, so only the tag-push preparation job can +# read them. RELEASE_ACTORS, the authenticated RELEASE_REVIEWERS comment receipt, and the environment's v* tag policy +# gate that job; the environment currently has no human reviewer. Anyone who can change those controls can sign software +# as you. Revoke the identities in Apple's developer +# portal if that stops being acceptable. Publication uses a separate `release-publish` environment with no signing input. set -euo pipefail cd "$(dirname "$0")/.." @@ -21,8 +23,8 @@ ask() { printf '%s' "$1" >&2; read -r REPLY; print -r -- "$REPLY"; } echo "Setting Actions secrets on $REPO, in the $ENVIRONMENT environment." gh api "repos/$REPO/environments/$ENVIRONMENT" >/dev/null 2>&1 || { - echo "No $ENVIRONMENT environment on $REPO. Create it first (Settings › Environments), with a required reviewer" >&2 - echo "and a deployment branch policy limited to the tag pattern v*." >&2 + echo "No $ENVIRONMENT environment on $REPO. Create it first (Settings › Environments)," >&2 + echo "with a deployment branch policy limited to the tag pattern v*." >&2 exit 1 } echo @@ -178,9 +180,9 @@ set_secret DMG_SIGN_IDENTITY "$APP_ID" set_secret INSTALLER_IDENTITY "$INSTALLER_ID" echo -echo "TAP_TOKEN is optional: a fine-grained token with Contents: write on xinbetween/homebrew-tap, which lets the" -echo "release job update the Homebrew cask. Add it to the same environment; without it that step is skipped and" -echo "you run scripts/update-cask.sh yourself." +echo "TAP_TOKEN is optional: a fine-grained token with Contents: write on xinbetween/homebrew-tap. Put it only in" +echo "the separate release-publish environment; never add signing or notarization secrets there. Without it, GitHub" +echo "publication still succeeds and you update the cask manually from the verified DMG." echo -echo "Try it against a tag that already exists: gh workflow run Release --repo $REPO --ref v0.2.2" -echo "It will wait for your approval before it touches any of this." +echo "A v* tag push prepares the signed artifact. After that run succeeds, publish with:" +echo " gh workflow run Release --repo $REPO --ref -f prepare_run_id=" diff --git a/scripts/classify_changes.py b/scripts/classify_changes.py index 156411be..777475a2 100644 --- a/scripts/classify_changes.py +++ b/scripts/classify_changes.py @@ -19,7 +19,7 @@ from dataclasses import asdict, dataclass -SCHEMA = "flowlight-review-v2" +SCHEMA = "flowlight-review-v3" SITE_ONLY_EXACT = frozenset( { diff --git a/scripts/notarize.sh b/scripts/notarize.sh index 46485a81..db68c79e 100755 --- a/scripts/notarize.sh +++ b/scripts/notarize.sh @@ -43,10 +43,12 @@ xcrun notarytool submit "$UPLOAD" "${CREDENTIALS[@]}" --wait # Apple's stapler occasionally loses its CloudKit connection after the notary service has already accepted the # upload (NSURLErrorDomain -1005). Re-submitting would create a second notarization request, so retry only the -# idempotent staple operation, only for that known transport failure, and with a short bounded backoff. +# idempotent staple operation, only for that known transport failure, and with a bounded capped backoff long +# enough to outlast a transient ticket-delivery outage. staple_attempt=1 staple_delay=2 -staple_max_attempts=4 +staple_max_delay=60 +staple_max_attempts=8 while true; do staple_log="$CLEANUP/stapler-$staple_attempt.log" set +e @@ -69,6 +71,9 @@ while true; do sleep "$staple_delay" (( staple_attempt += 1 )) (( staple_delay *= 2 )) + if (( staple_delay > staple_max_delay )); then + staple_delay=$staple_max_delay + fi done echo "Notarized and stapled: $TARGET" diff --git a/scripts/release_artifact.py b/scripts/release_artifact.py new file mode 100755 index 00000000..6cbe2d2e --- /dev/null +++ b/scripts/release_artifact.py @@ -0,0 +1,534 @@ +#!/usr/bin/env python3 +"""Create and verify Flowlight's build-once release handoff.""" + +import argparse +import datetime as dt +import hashlib +import json +import os +import pathlib +import re +import shutil +import stat +import sys +import zipfile + +SCHEMA = "flowlight-release-v1" +WORKFLOW_PATH = ".github/workflows/release.yml" +SHA256 = re.compile(r"[0-9a-f]{64}\Z") +SOURCE_SHA = re.compile(r"[0-9a-f]{40}\Z") +VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+\Z") +REQUIRED_REVIEW_CHECKS = { + ("github-actions", "changes"), + ("github-actions", "site"), + ("github-actions", "unit (oldest Xcode)"), + ("github-actions", "unit (newest Xcode)"), + ("github-actions", "Review / gate"), + ("github-actions", "CodeQL / gate"), + ("github-advanced-security", "CodeQL"), +} +REVIEW_WORKFLOWS = { + ("github-actions", "changes"): ".github/workflows/ci.yml", + ("github-actions", "site"): ".github/workflows/ci.yml", + ("github-actions", "unit (oldest Xcode)"): ".github/workflows/ci.yml", + ("github-actions", "unit (newest Xcode)"): ".github/workflows/ci.yml", + ("github-actions", "Review / gate"): ".github/workflows/ci.yml", + ("github-actions", "CodeQL / gate"): ".github/workflows/codeql.yml", +} + + +class ReleaseArtifactError(ValueError): + pass + + +def reject_duplicate_keys(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ReleaseArtifactError(f"duplicate JSON key: {key}") + result[key] = value + return result + + +def load_json(path): + try: + return json.loads(pathlib.Path(path).read_text(), object_pairs_hook=reject_duplicate_keys) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise ReleaseArtifactError(f"cannot read JSON from {path}: {error}") from error + + +def sha256(path): + digest = hashlib.sha256() + with pathlib.Path(path).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def file_record(path): + path = pathlib.Path(path) + return {"name": path.name, "size": path.stat().st_size, "sha256": sha256(path)} + + +def require_keys(value, expected, label): + if not isinstance(value, dict): + raise ReleaseArtifactError(f"{label} must be an object") + actual = set(value) + expected = set(expected) + if actual != expected: + raise ReleaseArtifactError( + f"{label} fields differ: missing={sorted(expected - actual)} extra={sorted(actual - expected)}" + ) + + +def require_plain_name(name, label): + if not isinstance(name, str) or not name or pathlib.PurePosixPath(name).name != name: + raise ReleaseArtifactError(f"{label} is not a plain filename: {name!r}") + if name in {".", ".."} or "/" in name or "\\" in name: + raise ReleaseArtifactError(f"{label} is not a plain filename: {name!r}") + + +def parse_timestamp(value, label): + if not isinstance(value, str): + raise ReleaseArtifactError(f"{label} must be an ISO-8601 timestamp") + try: + return dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise ReleaseArtifactError(f"{label} is not an ISO-8601 timestamp") from error + + +def verify_run(document, artifacts_document, *, repository, workflow_id, run_id, tag, source_sha, now=None): + if not str(run_id).isdigit(): + raise ReleaseArtifactError("prepare run ID must contain decimal digits only") + run_id = int(run_id) + if not SOURCE_SHA.fullmatch(source_sha): + raise ReleaseArtifactError("expected source SHA must be 40 lowercase hexadecimal characters") + if not isinstance(document, dict): + raise ReleaseArtifactError("run response must be an object") + checks = { + "run ID": (document.get("id"), run_id), + "repository": (document.get("repository", {}).get("full_name"), repository), + "workflow path": (document.get("path"), WORKFLOW_PATH), + "workflow ID": (document.get("workflow_id"), int(workflow_id)), + "event": (document.get("event"), "push"), + "tag": (document.get("head_branch"), tag), + "source SHA": (document.get("head_sha"), source_sha), + "status": (document.get("status"), "completed"), + "conclusion": (document.get("conclusion"), "success"), + } + for label, (actual, expected) in checks.items(): + if actual != expected: + raise ReleaseArtifactError(f"preparation {label} is {actual!r}, expected {expected!r}") + + if not isinstance(artifacts_document, dict) or not isinstance(artifacts_document.get("artifacts"), list): + raise ReleaseArtifactError("artifact response has an unexpected shape") + artifacts = artifacts_document["artifacts"] + if artifacts_document.get("total_count") != len(artifacts): + raise ReleaseArtifactError("artifact response count does not match its artifact list") + run_attempt = document.get("run_attempt") + if not isinstance(run_attempt, int) or run_attempt <= 0: + raise ReleaseArtifactError("preparation run attempt is missing or malformed") + expected_name = f"flowlight-release-{tag}-{run_id}-{run_attempt}" + matching = [artifact for artifact in artifacts if artifact.get("name") == expected_name] + if len(matching) != 1: + raise ReleaseArtifactError( + f"preparation attempt must have exactly one {expected_name!r} artifact, found {len(matching)}" + ) + artifact = matching[0] + if artifact.get("expired") is not False: + raise ReleaseArtifactError("prepared artifact is expired") + now = now or dt.datetime.now(dt.timezone.utc) + if parse_timestamp(artifact.get("expires_at"), "artifact expiry") <= now: + raise ReleaseArtifactError("prepared artifact has no remaining retention") + if artifact.get("workflow_run", {}).get("id") != run_id: + raise ReleaseArtifactError("artifact is not attached to the selected preparation run") + digest = artifact.get("digest") + if not isinstance(digest, str) or not digest.startswith("sha256:") or not SHA256.fullmatch(digest[7:]): + raise ReleaseArtifactError("artifact archive digest is missing or malformed") + if not isinstance(artifact.get("id"), int) or artifact["id"] <= 0: + raise ReleaseArtifactError("artifact ID is missing or malformed") + if not isinstance(artifact.get("size_in_bytes"), int) or artifact["size_in_bytes"] <= 0: + raise ReleaseArtifactError("artifact size is missing or empty") + return { + "artifact_id": artifact["id"], + "artifact_name": expected_name, + "artifact_digest": digest, + "run_attempt": run_attempt, + } + + +def create_manifest(args): + bundle = pathlib.Path(args.bundle) + version = args.version + if not VERSION.fullmatch(version): + raise ReleaseArtifactError("version must be three numeric components") + expected = [bundle / "Flowlight.dmg", bundle / f"Flowlight-{version}.pkg", bundle / "SHA256SUMS.txt"] + notes = bundle / "RELEASE-NOTES.md" + for path in [*expected, notes]: + if not path.is_file() or path.is_symlink(): + raise ReleaseArtifactError(f"missing regular prepared file: {path.name}") + evidence = load_json(args.evidence) + manifest = { + "schema": SCHEMA, + "repository": args.repository, + "tag": args.tag, + "source_sha": args.source_sha, + "source_tree": args.source_tree, + "version": version, + "build": str(args.build), + "preparation": { + "workflow": WORKFLOW_PATH, + "workflow_id": int(args.workflow_id), + "run_id": int(args.run_id), + "run_attempt": int(args.run_attempt), + }, + "review_evidence": evidence, + "assets": [file_record(path) for path in expected], + "release_notes": file_record(notes), + } + output = bundle / "RELEASE-MANIFEST.json" + output.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + return output + + +def verify_record(record, expected_name, path): + require_keys(record, {"name", "size", "sha256"}, f"record for {expected_name}") + require_plain_name(record["name"], "manifest filename") + if record["name"] != expected_name: + raise ReleaseArtifactError(f"manifest names {record['name']!r}, expected {expected_name!r}") + if not isinstance(record["size"], int) or record["size"] < 0: + raise ReleaseArtifactError(f"manifest size for {expected_name} is malformed") + if not isinstance(record["sha256"], str) or not SHA256.fullmatch(record["sha256"]): + raise ReleaseArtifactError(f"manifest SHA-256 for {expected_name} is malformed") + if path.stat().st_size != record["size"]: + raise ReleaseArtifactError(f"size mismatch for {expected_name}") + actual = sha256(path) + if actual != record["sha256"]: + raise ReleaseArtifactError(f"SHA-256 mismatch for {expected_name}") + + +def parse_checksums(path, expected_names): + try: + lines = path.read_text().splitlines() + except (OSError, UnicodeError) as error: + raise ReleaseArtifactError(f"cannot read checksum file: {error}") from error + if len(lines) != len(expected_names): + raise ReleaseArtifactError("checksum file must contain exactly one line per binary asset") + checksums = {} + for line in lines: + match = re.fullmatch(r"([0-9a-f]{64}) [ *]([^/\\]+)", line) + if not match: + raise ReleaseArtifactError(f"malformed checksum line: {line!r}") + digest, name = match.groups() + require_plain_name(name, "checksum filename") + if name in checksums: + raise ReleaseArtifactError(f"duplicate checksum entry: {name}") + checksums[name] = digest + if set(checksums) != set(expected_names): + raise ReleaseArtifactError("checksum filenames do not match the binary assets") + return checksums + + +def verify_review_evidence(evidence, repository, source_sha): + require_keys( + evidence, + {"repository", "source_sha", "pull_requests", "review_receipts", "checks"}, + "review evidence", + ) + if evidence["repository"] != repository or evidence["source_sha"] != source_sha: + raise ReleaseArtifactError("manifest review evidence does not bind the release repository and source SHA") + pulls = evidence["pull_requests"] + if not isinstance(pulls, list) or not pulls or any(not isinstance(number, int) or number <= 0 for number in pulls): + raise ReleaseArtifactError("review evidence must name at least one valid pull request") + if len(set(pulls)) != len(pulls): + raise ReleaseArtifactError("review evidence contains duplicate pull requests") + receipts = evidence["review_receipts"] + if not isinstance(receipts, list) or not receipts: + raise ReleaseArtifactError("review evidence must retain an authenticated review receipt") + receipt_ids = set() + trusted_associations = {"OWNER", "MEMBER", "COLLABORATOR"} + for receipt in receipts: + require_keys( + receipt, + {"pull_request", "comment_id", "reviewer", "author_association", "reviewed_commit", "url"}, + "review receipt", + ) + pull_number = receipt["pull_request"] + comment_id = receipt["comment_id"] + if pull_number not in pulls: + raise ReleaseArtifactError("review receipt does not belong to an exact-head pull request") + if not isinstance(comment_id, int) or isinstance(comment_id, bool) or comment_id <= 0: + raise ReleaseArtifactError("review receipt comment ID is malformed") + if comment_id in receipt_ids: + raise ReleaseArtifactError("review evidence contains duplicate review receipt IDs") + receipt_ids.add(comment_id) + reviewer = receipt["reviewer"] + if not isinstance(reviewer, str) or not reviewer.strip(): + raise ReleaseArtifactError("review receipt reviewer is malformed") + if receipt["author_association"] not in trusted_associations: + raise ReleaseArtifactError("review receipt author association is not trusted") + if receipt["reviewed_commit"] != source_sha: + raise ReleaseArtifactError("review receipt does not bind the exact release commit") + expected_url = f"https://github.com/{repository}/pull/{pull_number}#issuecomment-{comment_id}" + if receipt["url"] != expected_url: + raise ReleaseArtifactError("review receipt URL does not bind its pull request and comment ID") + checks = evidence["checks"] + if not isinstance(checks, list) or len(checks) != len(REQUIRED_REVIEW_CHECKS): + raise ReleaseArtifactError("review evidence does not contain every required check exactly once") + found = set() + for check in checks: + require_keys( + check, + {"app", "name", "id", "details_url", "workflow", "workflow_run_id", "pull_request"}, + "review check", + ) + key = (check["app"], check["name"]) + if key in found or key not in REQUIRED_REVIEW_CHECKS: + raise ReleaseArtifactError(f"unexpected or duplicate review check: {key}") + if not isinstance(check["id"], int) or check["id"] <= 0: + raise ReleaseArtifactError(f"review check has an invalid ID: {key}") + if not isinstance(check["details_url"], str) or not check["details_url"].startswith(f"https://github.com/{repository}/"): + raise ReleaseArtifactError(f"review check points outside the release repository: {key}") + expected_workflow = REVIEW_WORKFLOWS.get(key) + if expected_workflow: + if check["workflow"] != expected_workflow: + raise ReleaseArtifactError(f"review check came from the wrong workflow: {key}") + if not isinstance(check["workflow_run_id"], int) or check["workflow_run_id"] <= 0: + raise ReleaseArtifactError(f"review check has no trusted workflow run ID: {key}") + if check["pull_request"] not in pulls: + raise ReleaseArtifactError(f"review check is not bound to an exact-head reviewed pull request: {key}") + run_url = re.fullmatch( + rf"https://github\.com/{re.escape(repository)}/actions/runs/([0-9]+)(?:/.*)?", + check["details_url"], + ) + if not run_url or int(run_url.group(1)) != check["workflow_run_id"]: + raise ReleaseArtifactError(f"review check URL does not bind its workflow run ID: {key}") + elif check["workflow"] is not None or check["workflow_run_id"] is not None or check["pull_request"] is not None: + raise ReleaseArtifactError(f"non-Actions review check unexpectedly names workflow or pull-request provenance: {key}") + found.add(key) + if found != REQUIRED_REVIEW_CHECKS: + raise ReleaseArtifactError("review evidence check set is incomplete") + + +def extract_archive(archive, destination, *, version, max_expanded_size=2_000_000_000): + archive = pathlib.Path(archive) + destination = pathlib.Path(destination) + if not VERSION.fullmatch(version): + raise ReleaseArtifactError("version must be three numeric components") + if destination.exists(): + if not destination.is_dir() or any(destination.iterdir()): + raise ReleaseArtifactError("archive destination must be an empty directory") + else: + destination.mkdir() + expected = { + "Flowlight.dmg", + f"Flowlight-{version}.pkg", + "SHA256SUMS.txt", + "RELEASE-NOTES.md", + "RELEASE-MANIFEST.json", + } + try: + with zipfile.ZipFile(archive) as bundle: + infos = bundle.infolist() + names = [entry.filename for entry in infos] + if len(names) != len(set(names)): + raise ReleaseArtifactError("prepared archive contains duplicate paths") + if set(names) != expected or len(infos) != len(expected): + raise ReleaseArtifactError(f"prepared archive paths differ: {sorted(names)}") + expanded_size = sum(entry.file_size for entry in infos) + if expanded_size > max_expanded_size: + raise ReleaseArtifactError("prepared archive expands beyond the release size bound") + for entry in infos: + require_plain_name(entry.filename, "prepared archive path") + mode = entry.external_attr >> 16 + if mode and not stat.S_ISREG(mode): + raise ReleaseArtifactError( + f"prepared archive entry is not a regular file: {entry.filename}" + ) + if entry.flag_bits & 1: + raise ReleaseArtifactError(f"prepared archive entry is encrypted: {entry.filename}") + for entry in infos: + target = destination / entry.filename + with bundle.open(entry) as source, target.open("xb") as output: + shutil.copyfileobj(source, output) + except (zipfile.BadZipFile, zipfile.LargeZipFile, RuntimeError) as error: + raise ReleaseArtifactError(f"prepared archive cannot be extracted safely: {error}") from error + return destination + + +def verify_bundle(bundle, *, repository, tag, source_sha, version, build, workflow_id, run_id, run_attempt, public=False): + bundle = pathlib.Path(bundle) + if not bundle.is_dir(): + raise ReleaseArtifactError(f"bundle is not a directory: {bundle}") + names = set() + for entry in bundle.iterdir(): + mode = entry.lstat().st_mode + if not stat.S_ISREG(mode): + raise ReleaseArtifactError(f"bundle entry is not a regular file: {entry.name}") + require_plain_name(entry.name, "bundle entry") + names.add(entry.name) + pkg_name = f"Flowlight-{version}.pkg" + prepared_names = {"Flowlight.dmg", pkg_name, "SHA256SUMS.txt", "RELEASE-NOTES.md", "RELEASE-MANIFEST.json"} + public_names = prepared_names - {"RELEASE-NOTES.md"} + expected_names = public_names if public else prepared_names + if names != expected_names: + raise ReleaseArtifactError( + f"bundle files differ: missing={sorted(expected_names - names)} extra={sorted(names - expected_names)}" + ) + + manifest = load_json(bundle / "RELEASE-MANIFEST.json") + require_keys( + manifest, + {"schema", "repository", "tag", "source_sha", "source_tree", "version", "build", "preparation", "review_evidence", "assets", "release_notes"}, + "manifest", + ) + expected_scalars = { + "schema": SCHEMA, + "repository": repository, + "tag": tag, + "source_sha": source_sha, + "version": version, + "build": str(build), + } + for field, expected in expected_scalars.items(): + if manifest[field] != expected: + raise ReleaseArtifactError(f"manifest {field} is {manifest[field]!r}, expected {expected!r}") + if not SOURCE_SHA.fullmatch(manifest["source_sha"]): + raise ReleaseArtifactError("manifest source SHA is malformed") + if not SOURCE_SHA.fullmatch(manifest["source_tree"]): + raise ReleaseArtifactError("manifest source tree is malformed") + if not VERSION.fullmatch(manifest["version"]): + raise ReleaseArtifactError("manifest version is malformed") + preparation = manifest["preparation"] + require_keys(preparation, {"workflow", "workflow_id", "run_id", "run_attempt"}, "preparation") + if preparation != { + "workflow": WORKFLOW_PATH, + "workflow_id": int(workflow_id), + "run_id": int(run_id), + "run_attempt": int(run_attempt), + }: + raise ReleaseArtifactError("manifest preparation identity does not match the selected run") + if preparation["run_attempt"] <= 0: + raise ReleaseArtifactError("manifest run attempt is malformed") + verify_review_evidence(manifest["review_evidence"], repository, source_sha) + + assets = manifest["assets"] + if not isinstance(assets, list) or len(assets) != 3: + raise ReleaseArtifactError("manifest must describe exactly three non-manifest public assets") + records = {} + for record in assets: + if not isinstance(record, dict) or not isinstance(record.get("name"), str): + raise ReleaseArtifactError("manifest asset record is malformed") + if record["name"] in records: + raise ReleaseArtifactError(f"duplicate manifest asset: {record['name']}") + records[record["name"]] = record + expected_asset_names = {"Flowlight.dmg", pkg_name, "SHA256SUMS.txt"} + if set(records) != expected_asset_names: + raise ReleaseArtifactError("manifest asset names do not match the release assets") + for name in expected_asset_names: + verify_record(records[name], name, bundle / name) + + notes_record = manifest["release_notes"] + require_keys(notes_record, {"name", "size", "sha256"}, "release notes record") + if notes_record.get("name") != "RELEASE-NOTES.md": + raise ReleaseArtifactError("release notes record has an unexpected name") + if not isinstance(notes_record.get("size"), int) or notes_record["size"] <= 0: + raise ReleaseArtifactError("release notes must not be empty") + if not isinstance(notes_record.get("sha256"), str) or not SHA256.fullmatch(notes_record["sha256"]): + raise ReleaseArtifactError("release notes digest is malformed") + if not public: + verify_record(notes_record, "RELEASE-NOTES.md", bundle / "RELEASE-NOTES.md") + + checksums = parse_checksums(bundle / "SHA256SUMS.txt", ["Flowlight.dmg", pkg_name]) + for name, digest in checksums.items(): + actual = sha256(bundle / name) + if digest != actual or digest != records[name]["sha256"]: + raise ReleaseArtifactError(f"checksum disagreement for {name}") + return manifest + + +def parser(): + root = argparse.ArgumentParser() + commands = root.add_subparsers(dest="command", required=True) + + run = commands.add_parser("verify-run") + run.add_argument("--run-json", required=True) + run.add_argument("--artifacts-json", required=True) + run.add_argument("--repository", required=True) + run.add_argument("--workflow-id", required=True, type=int) + run.add_argument("--run-id", required=True) + run.add_argument("--tag", required=True) + run.add_argument("--source-sha", required=True) + + extract = commands.add_parser("extract-archive") + extract.add_argument("--archive", required=True) + extract.add_argument("--destination", required=True) + extract.add_argument("--version", required=True) + + create = commands.add_parser("create-manifest") + create.add_argument("--bundle", required=True) + create.add_argument("--repository", required=True) + create.add_argument("--tag", required=True) + create.add_argument("--source-sha", required=True) + create.add_argument("--source-tree", required=True) + create.add_argument("--version", required=True) + create.add_argument("--build", required=True) + create.add_argument("--workflow-id", required=True, type=int) + create.add_argument("--run-id", required=True, type=int) + create.add_argument("--run-attempt", required=True, type=int) + create.add_argument("--evidence", required=True) + + verify = commands.add_parser("verify-bundle") + verify.add_argument("--bundle", required=True) + verify.add_argument("--repository", required=True) + verify.add_argument("--tag", required=True) + verify.add_argument("--source-sha", required=True) + verify.add_argument("--version", required=True) + verify.add_argument("--build", required=True) + verify.add_argument("--workflow-id", required=True, type=int) + verify.add_argument("--run-id", required=True, type=int) + verify.add_argument("--run-attempt", required=True, type=int) + verify.add_argument("--public", action="store_true") + return root + + +def main(): + args = parser().parse_args() + try: + if args.command == "verify-run": + result = verify_run( + load_json(args.run_json), + load_json(args.artifacts_json), + repository=args.repository, + workflow_id=args.workflow_id, + run_id=args.run_id, + tag=args.tag, + source_sha=args.source_sha, + ) + print(json.dumps(result, sort_keys=True)) + elif args.command == "extract-archive": + print(extract_archive(args.archive, args.destination, version=args.version)) + elif args.command == "create-manifest": + print(create_manifest(args)) + else: + verify_bundle( + args.bundle, + repository=args.repository, + tag=args.tag, + source_sha=args.source_sha, + version=args.version, + build=args.build, + workflow_id=args.workflow_id, + run_id=args.run_id, + run_attempt=args.run_attempt, + public=args.public, + ) + print(f"Verified release bundle in {args.bundle}") + except (OSError, ReleaseArtifactError) as error: + print(f"release artifact rejected: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/screenshots.sh b/scripts/screenshots.sh index a88f4432..840e1e8c 100755 --- a/scripts/screenshots.sh +++ b/scripts/screenshots.sh @@ -46,11 +46,15 @@ SHOTS=( "inspect:inspect::" "visualize:visualize::1600x994" ) -# Rules is Store-capable but not one of the website's ten published assets. Add it only when an explicit capture set -# asks for it, keeping the default website inventory and its all-images manifest stable. +# Policies is Store-capable but not one of the website's ten published assets. The internal `rules` screen value and +# output filename remain stable for screenshot automation and old arguments. if [ -n "${FL_ONLY:-}" ] && [[ ",${FL_ONLY}," == *",rules,"* ]]; then SHOTS+=("rules:rules::") fi +# Sources is likewise available for release acceptance without expanding the website's committed image inventory. +if [ -n "${FL_ONLY:-}" ] && [[ ",${FL_ONLY}," == *",sources,"* ]]; then + SHOTS+=("sources:sources::") +fi exact_app_pids() { local expected="$1" pid command @@ -80,7 +84,8 @@ expected_window_title() { agents) print -r -- "AI Agents" ;; reports) print -r -- "History" ;; alerts) print -r -- "Alerts" ;; - rules) print -r -- "Rules" ;; + rules) print -r -- "Policies" ;; + sources) print -r -- "Sources" ;; inspect) print -r -- "Inspect" ;; visualize) print -r -- "Connections" ;; *) echo " no expected window title for screen $1" >&2; return 1 ;; @@ -92,7 +97,7 @@ title_matches_screen() { expected=$(expected_window_title "$screen") || return 1 case "$screen" in alerts) [[ "$actual" == "$expected" || "$actual" == "$expected – "* ]] ;; - rules) [[ "$actual" == "$expected" || "$actual" == "$expected – "*" in force" ]] ;; + rules) [[ "$actual" == "$expected" || "$actual" == "$expected – "* ]] ;; inspect) [[ "$actual" == "$expected" || "$actual" == "$expected – Off" ]] ;; *) [[ "$actual" == "$expected" ]] ;; esac diff --git a/scripts/test-notarize.sh b/scripts/test-notarize.sh index 401ac418..60fa9e5f 100755 --- a/scripts/test-notarize.sh +++ b/scripts/test-notarize.sh @@ -101,10 +101,10 @@ set -e print -u2 -- "expected persistent -1005 failures to exit 65, got $fixture_status" exit 1 } -[[ "$(< "$temporary/exhaust/count")" == 4 ]] +[[ "$(< "$temporary/exhaust/count")" == 8 ]] [[ "$(wc -l < "$temporary/exhaust/notary.log" | tr -d ' ')" == 1 ]] -[[ "$(< "$temporary/exhaust/sleep.log")" == $'2\n4\n8' ]] -grep -q 'Stapling failed after 4 attempts' "$temporary/exhaust.out" +[[ "$(< "$temporary/exhaust/sleep.log")" == $'2\n4\n8\n16\n32\n60\n60' ]] +grep -q 'Stapling failed after 8 attempts' "$temporary/exhaust.out" set +e run_fixture permanent > "$temporary/permanent.out" 2>&1 @@ -190,4 +190,4 @@ second_upload=$(awk '{print $3}' "$concurrent_case/second/notary.log") [[ ! -e "${first_upload:h}" ]] [[ ! -e "${second_upload:h}" ]] -print -- 'Notarization fixtures passed: immediate success, recovery at 3, exhaustion at 4, permanent and mixed-code failure at 1, submit failure at 0 staples, failed-app cleanup, and isolated concurrent uploads.' +print -- 'Notarization fixtures passed: immediate success, recovery at 3, capped exhaustion at 8, permanent and mixed-code failure at 1, submit failure at 0 staples, failed-app cleanup, and isolated concurrent uploads.' diff --git a/scripts/test-update-cask.sh b/scripts/test-update-cask.sh new file mode 100755 index 00000000..c93f1211 --- /dev/null +++ b/scripts/test-update-cask.sh @@ -0,0 +1,45 @@ +#!/bin/zsh +set -euo pipefail + +root=${0:A:h:h} +temporary=$(mktemp -d "${TMPDIR:-/tmp}/flowlight-cask-test.XXXXXX") +trap 'rm -rf "$temporary"' EXIT + +cask="$temporary/flowlight.rb" +dmg="$temporary/Flowlight.dmg" +tap="$temporary/tap" +cat > "$cask" <<'CASK' +cask "flowlight" do + version "0.0.0" + sha256 "old" +end +CASK +print -n -- 'trusted prepared dmg fixture' > "$dmg" +mkdir -p "$tap/Casks" +git -C "$tap" init -q +git -C "$tap" config user.name fixture +git -C "$tap" config user.email fixture@example.com +print -- fixture > "$tap/README" +git -C "$tap" add README +git -C "$tap" commit -qm fixture + +VERSION=9.8.7 REQUIRE_LOCAL_DMG=1 CASK_PATH="$cask" DMG_PATH="$dmg" TAP="$tap" \ + "$root/scripts/update-cask.sh" +expected=$(shasum -a 256 "$dmg" | cut -d' ' -f1) +grep -qx ' version "9.8.7"' "$tap/Casks/flowlight.rb" +grep -qx " sha256 \"$expected\"" "$tap/Casks/flowlight.rb" +first_commit=$(git -C "$tap" rev-parse HEAD) + +VERSION=9.8.7 REQUIRE_LOCAL_DMG=1 CASK_PATH="$cask" DMG_PATH="$dmg" TAP="$tap" \ + "$root/scripts/update-cask.sh" +[[ "$(git -C "$tap" rev-parse HEAD)" == "$first_commit" ]] + +set +e +VERSION=9.8.7 REQUIRE_LOCAL_DMG=1 CASK_PATH="$cask" DMG_PATH="$temporary/missing.dmg" TAP="$tap" \ + "$root/scripts/update-cask.sh" > "$temporary/missing.out" 2>&1 +missing_status=$? +set -e +[[ "$missing_status" != 0 ]] +grep -q 'REQUIRE_LOCAL_DMG is set' "$temporary/missing.out" + +print -- 'Homebrew cask fixtures passed: portable rewrite, exact digest, idempotent rerun, and missing-local failure.' diff --git a/scripts/test_chart_palette.py b/scripts/test_chart_palette.py new file mode 100644 index 00000000..ccd1ec84 --- /dev/null +++ b/scripts/test_chart_palette.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Fail-capable tests for the Flowlight chart palette contract.""" + +from __future__ import annotations + +import importlib.util +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts/check_chart_palette.py" +SPEC = importlib.util.spec_from_file_location("check_chart_palette", MODULE_PATH) +assert SPEC and SPEC.loader +PALETTE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PALETTE) +SOURCE = ROOT / "Flowlight/UI/DesignTokens.swift" + + +class ChartPaletteTests(unittest.TestCase): + def test_committed_light_and_dark_palettes_pass(self): + tokens = PALETTE.parse_tokens(SOURCE.read_text(encoding="utf-8")) + self.assertEqual(PALETTE.validate(tokens), []) + + def test_directional_collision_is_rejected_under_normal_and_cvd_vision(self): + tokens = PALETTE.parse_tokens(SOURCE.read_text(encoding="utf-8")) + tokens["sent"] = dict(tokens["received"]) + issues = PALETTE.validate(tokens) + joined = "\n".join(issues) + self.assertIn("directional received/sent normal Delta E", joined) + self.assertIn("directional received/sent protan Delta E", joined) + self.assertIn("directional received/sent deutan Delta E", joined) + + def test_low_contrast_mark_is_rejected_in_both_modes(self): + tokens = PALETTE.parse_tokens(SOURCE.read_text(encoding="utf-8")) + tokens["received"] = dict(tokens["surface"]) + issues = "\n".join(PALETTE.validate(tokens)) + self.assertIn("light received contrast", issues) + self.assertIn("dark received contrast", issues) + + def test_missing_semantic_token_fails_closed(self): + tokens = PALETTE.parse_tokens(SOURCE.read_text(encoding="utf-8")) + del tokens["graphInvocation"] + self.assertEqual(PALETTE.validate(tokens), ["missing semantic token: graphInvocation"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_check_site.py b/scripts/test_check_site.py index cd1af2f2..912faef9 100644 --- a/scripts/test_check_site.py +++ b/scripts/test_check_site.py @@ -57,5 +57,66 @@ def test_universal_sources_shortcut_fails_store_requirement(self): "⌘9 · Developer ID
⌘8 · App Store", required), []) +class ScreenshotProvenanceTests(unittest.TestCase): + def test_truthful_prior_capture_version_is_allowed(self): + entries = { + "agents.png": {"version": "0.17.4"}, + "alerts.png": {"version": "0.17.4"}, + } + self.assertEqual(check_site.capture_version_problems(entries, "0.17.5", "screenshots"), []) + + def test_mixed_truthful_capture_versions_are_allowed(self): + entries = { + "agents.png": {"version": "0.17.4"}, + "alerts.png": {"version": "0.17.5"}, + } + self.assertEqual(check_site.capture_version_problems(entries, "0.17.5", "screenshots"), []) + + def test_future_and_missing_capture_versions_fail(self): + self.assertEqual( + check_site.capture_version_problems( + {"agents.png": {"version": "0.18.0"}}, "0.17.5", "screenshots" + ), + ["screenshots: capture version is newer than app 0.17.5: 0.18.0"], + ) + self.assertEqual( + check_site.capture_version_problems( + {"agents.png": {}}, "0.17.5", "screenshots" + ), + ["screenshots: agents.png has no capture version"], + ) + + +class PagesProvenanceTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.source = check_site.PAGES_WORKFLOW.read_text() + + def test_committed_workflow_uses_verified_ci_artifact(self): + self.assertEqual(check_site.pages_workflow_problems(self.source), []) + + def test_source_bypassing_trigger_fails(self): + unsafe = self.source.replace(" workflow_run:\n", " workflow_dispatch:\n workflow_run:\n", 1) + self.assertIn("Pages has a source-bypassing trigger", check_site.pages_workflow_problems(unsafe)) + + def test_checkout_or_rebuild_fails(self): + unsafe = self.source.replace(" steps:\n", " steps:\n - uses: actions/checkout@v4\n", 1) + self.assertIn("Pages checks out or rebuilds source", check_site.pages_workflow_problems(unsafe)) + + def test_missing_exact_artifact_consumption_fails(self): + unsafe = self.source.replace("consume-pages", "consume-unverified-pages", 1) + self.assertIn( + "Pages does not consume and upload the verified CI artifact", + check_site.pages_workflow_problems(unsafe), + ) + + def test_extra_deploy_permission_fails(self): + unsafe = self.source.replace(" pages: write\n", " pages: write\n contents: write\n", 1) + self.assertIn( + "Pages deploy permissions are not limited to pages and id-token write", + check_site.pages_workflow_problems(unsafe), + ) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/test_classify_changes.py b/scripts/test_classify_changes.py index a3153574..640d9886 100644 --- a/scripts/test_classify_changes.py +++ b/scripts/test_classify_changes.py @@ -105,7 +105,7 @@ def test_site_change_keeps_fingerprint_and_skips_app(self): self.assertFalse(result.app) self.assertFalse(result.python) self.assertFalse(result.actions) - self.assertEqual(result.schema, "flowlight-review-v2") + self.assertEqual(result.schema, "flowlight-review-v3") self.assertEqual(result.app_fingerprint, before) def test_site_python_change_routes_python_without_app_or_actions(self): diff --git a/scripts/test_release_artifact.py b/scripts/test_release_artifact.py new file mode 100755 index 00000000..eaa2240d --- /dev/null +++ b/scripts/test_release_artifact.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 + +import copy +import datetime as dt +import hashlib +import importlib.util +import json +import pathlib +import stat +import sys +import tempfile +import types +import unittest +import warnings +import zipfile + +SCRIPT = pathlib.Path(__file__).with_name("release_artifact.py") +SPEC = importlib.util.spec_from_file_location("release_artifact", SCRIPT) +assert SPEC and SPEC.loader +release_artifact = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = release_artifact +SPEC.loader.exec_module(release_artifact) +ReleaseArtifactError = release_artifact.ReleaseArtifactError +verify_bundle = release_artifact.verify_bundle +create_manifest = release_artifact.create_manifest +extract_archive = release_artifact.extract_archive +verify_run = release_artifact.verify_run +REQUIRED_REVIEW_CHECKS = release_artifact.REQUIRED_REVIEW_CHECKS + +REPOSITORY = "xinbetween/flowlight" +TAG = "v0.17.4" +SHA = "a" * 40 +TREE = "b" * 40 +VERSION = "0.17.4" +BUILD = "78" +WORKFLOW_ID = 123 +RUN_ID = 456 + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def run_fixture(): + run = { + "id": RUN_ID, + "repository": {"full_name": REPOSITORY}, + "path": ".github/workflows/release.yml", + "workflow_id": WORKFLOW_ID, + "event": "push", + "head_branch": TAG, + "head_sha": SHA, + "status": "completed", + "conclusion": "success", + "run_attempt": 1, + } + artifact = { + "id": 789, + "name": f"flowlight-release-{TAG}-{RUN_ID}-1", + "expired": False, + "expires_at": "2030-01-01T00:00:00Z", + "workflow_run": {"id": RUN_ID}, + "digest": f"sha256:{'c' * 64}", + "size_in_bytes": 1234, + } + return run, {"total_count": 1, "artifacts": [artifact]} + + +def write_bundle(root): + root = pathlib.Path(root) + files = { + "Flowlight.dmg": b"signed dmg fixture", + f"Flowlight-{VERSION}.pkg": b"signed pkg fixture", + } + for name, data in files.items(): + (root / name).write_bytes(data) + sums = "".join(f"{digest(data)} {name}\n" for name, data in files.items()).encode() + (root / "SHA256SUMS.txt").write_bytes(sums) + notes = b"Flowlight 0.17.4 release notes\n\n### Checksums\n" + (root / "RELEASE-NOTES.md").write_bytes(notes) + asset_names = ["Flowlight.dmg", f"Flowlight-{VERSION}.pkg", "SHA256SUMS.txt"] + manifest = { + "schema": "flowlight-release-v1", + "repository": REPOSITORY, + "tag": TAG, + "source_sha": SHA, + "source_tree": TREE, + "version": VERSION, + "build": BUILD, + "preparation": { + "workflow": ".github/workflows/release.yml", + "workflow_id": WORKFLOW_ID, + "run_id": RUN_ID, + "run_attempt": 1, + }, + "review_evidence": { + "repository": REPOSITORY, + "source_sha": SHA, + "pull_requests": [82], + "review_receipts": [{ + "pull_request": 82, + "comment_id": 9_001, + "reviewer": "release-reviewer", + "author_association": "OWNER", + "reviewed_commit": SHA, + "url": f"https://github.com/{REPOSITORY}/pull/82#issuecomment-9001", + }], + "checks": [ + { + "app": app, + "name": name, + "id": index, + "details_url": f"https://github.com/{REPOSITORY}/actions/runs/{index}", + "workflow": release_artifact.REVIEW_WORKFLOWS.get((app, name)), + "workflow_run_id": index if (app, name) in release_artifact.REVIEW_WORKFLOWS else None, + "pull_request": 82 if (app, name) in release_artifact.REVIEW_WORKFLOWS else None, + } + for index, (app, name) in enumerate(sorted(REQUIRED_REVIEW_CHECKS), start=1) + ], + }, + "assets": [ + {"name": name, "size": (root / name).stat().st_size, "sha256": digest((root / name).read_bytes())} + for name in asset_names + ], + "release_notes": {"name": "RELEASE-NOTES.md", "size": len(notes), "sha256": digest(notes)}, + } + (root / "RELEASE-MANIFEST.json").write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + + +def verify(root, public=False): + return verify_bundle( + root, + repository=REPOSITORY, + tag=TAG, + source_sha=SHA, + version=VERSION, + build=BUILD, + workflow_id=WORKFLOW_ID, + run_id=RUN_ID, + run_attempt=1, + public=public, + ) + + +class PreparationRunTests(unittest.TestCase): + def test_accepts_exact_successful_tag_push(self): + run, artifacts = run_fixture() + result = verify_run( + run, artifacts, repository=REPOSITORY, workflow_id=WORKFLOW_ID, + run_id=str(RUN_ID), tag=TAG, source_sha=SHA, + now=dt.datetime(2029, 1, 1, tzinfo=dt.timezone.utc), + ) + self.assertEqual(result["artifact_id"], 789) + + def test_accepts_current_attempt_alongside_failed_previous_attempt_artifact(self): + run, artifacts = run_fixture() + run["run_attempt"] = 2 + previous = dict(artifacts["artifacts"][0]) + current = dict(previous, id=790, name=f"flowlight-release-{TAG}-{RUN_ID}-2") + artifacts = {"total_count": 2, "artifacts": [previous, current]} + + result = verify_run( + run, artifacts, repository=REPOSITORY, workflow_id=WORKFLOW_ID, + run_id=str(RUN_ID), tag=TAG, source_sha=SHA, + now=dt.datetime(2029, 1, 1, tzinfo=dt.timezone.utc), + ) + + self.assertEqual(result["artifact_id"], 790) + self.assertEqual(result["run_attempt"], 2) + + def test_rejects_wrong_run_metadata_and_artifacts(self): + mutations = { + "nonnumeric run ID": lambda run, artifacts: (run, artifacts, "not-a-run"), + "another repository": lambda run, artifacts: (self._set(run, "repository", {"full_name": "other/repo"}), artifacts, str(RUN_ID)), + "another workflow": lambda run, artifacts: (self._set(run, "path", ".github/workflows/ci.yml"), artifacts, str(RUN_ID)), + "another workflow ID": lambda run, artifacts: (self._set(run, "workflow_id", 999), artifacts, str(RUN_ID)), + "dispatch run": lambda run, artifacts: (self._set(run, "event", "workflow_dispatch"), artifacts, str(RUN_ID)), + "another tag": lambda run, artifacts: (self._set(run, "head_branch", "v9.9.9"), artifacts, str(RUN_ID)), + "another SHA": lambda run, artifacts: (self._set(run, "head_sha", "d" * 40), artifacts, str(RUN_ID)), + "in progress": lambda run, artifacts: (self._set(run, "status", "in_progress"), artifacts, str(RUN_ID)), + "failed": lambda run, artifacts: (self._set(run, "conclusion", "failure"), artifacts, str(RUN_ID)), + "missing artifact": lambda run, artifacts: (run, {"total_count": 0, "artifacts": []}, str(RUN_ID)), + "duplicate artifacts": lambda run, artifacts: (run, {"total_count": 2, "artifacts": artifacts["artifacts"] * 2}, str(RUN_ID)), + "expired artifact": lambda run, artifacts: (run, self._artifact(artifacts, expired=True), str(RUN_ID)), + "wrong artifact name": lambda run, artifacts: (run, self._artifact(artifacts, name="other"), str(RUN_ID)), + "wrong attached run": lambda run, artifacts: (run, self._artifact(artifacts, workflow_run={"id": 999}), str(RUN_ID)), + "malformed digest": lambda run, artifacts: (run, self._artifact(artifacts, digest="sha256:BAD"), str(RUN_ID)), + } + for label, mutate in mutations.items(): + with self.subTest(label=label): + run, artifacts = run_fixture() + run, artifacts, run_id = mutate(run, artifacts) + with self.assertRaises(ReleaseArtifactError): + verify_run( + run, artifacts, repository=REPOSITORY, workflow_id=WORKFLOW_ID, + run_id=run_id, tag=TAG, source_sha=SHA, + now=dt.datetime(2029, 1, 1, tzinfo=dt.timezone.utc), + ) + + @staticmethod + def _set(document, key, value): + document[key] = value + return document + + @staticmethod + def _artifact(document, **changes): + document["artifacts"][0].update(changes) + return document + + +class PreparedArchiveTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.temporary.name) + self.bundle = self.root / "bundle" + self.bundle.mkdir() + write_bundle(self.bundle) + self.archive = self.root / "prepared.zip" + self.destination = self.root / "extracted" + + def tearDown(self): + self.temporary.cleanup() + + def _entries(self): + return {path.name: path.read_bytes() for path in self.bundle.iterdir()} + + def _write_custom(self, entries, *, duplicate=None, special_mode=None): + special_mode = special_mode or {} + with zipfile.ZipFile(self.archive, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for name, data in entries.items(): + info = zipfile.ZipInfo(name) + info.create_system = 3 + info.external_attr = special_mode.get(name, stat.S_IFREG | 0o644) << 16 + archive.writestr(info, data) + if duplicate: + info = zipfile.ZipInfo(duplicate) + info.create_system = 3 + info.external_attr = (stat.S_IFREG | 0o644) << 16 + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + archive.writestr(info, entries[duplicate]) + + def test_extracts_exact_archive_and_round_trips_bundle(self): + with zipfile.ZipFile(self.archive, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in self.bundle.iterdir(): + archive.write(path, path.name) + extract_archive(self.archive, self.destination, version=VERSION) + verify(self.destination) + + def test_rejects_unsafe_or_substituted_archive_entries(self): + def extra(entries): + entries["extra"] = b"extra" + return entries, {} + + def traversal(entries): + entries["../Flowlight.dmg"] = entries.pop("Flowlight.dmg") + return entries, {} + + def wrong_package(entries): + entries["Flowlight-9.9.9.pkg"] = entries.pop(f"Flowlight-{VERSION}.pkg") + return entries, {} + + def symlink(entries): + return entries, {"Flowlight.dmg": stat.S_IFLNK | 0o777} + + def directory(entries): + return entries, {"Flowlight.dmg": stat.S_IFDIR | 0o755} + + cases = { + "extra path": extra, + "traversal path": traversal, + "wrong package": wrong_package, + "symlink entry": symlink, + "directory entry": directory, + } + for label, mutate in cases.items(): + with self.subTest(label=label): + entries, modes = mutate(self._entries()) + self._write_custom(entries, special_mode=modes) + with self.assertRaises(ReleaseArtifactError): + extract_archive(self.archive, self.destination, version=VERSION) + if self.destination.exists(): + for path in self.destination.iterdir(): + path.unlink() + + def test_rejects_duplicate_corrupt_and_oversized_archives(self): + self._write_custom(self._entries(), duplicate="Flowlight.dmg") + with self.assertRaises(ReleaseArtifactError): + extract_archive(self.archive, self.destination, version=VERSION) + + self.destination.rmdir() + self.archive.write_bytes(b"not a zip") + with self.assertRaises(ReleaseArtifactError): + extract_archive(self.archive, self.destination, version=VERSION) + + self.archive.unlink() + self._write_custom(self._entries()) + with self.assertRaises(ReleaseArtifactError): + extract_archive(self.archive, self.destination, version=VERSION, max_expanded_size=1) + + +class PreparedBundleTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = pathlib.Path(self.temporary.name) + write_bundle(self.root) + + def tearDown(self): + self.temporary.cleanup() + + def test_create_manifest_round_trip(self): + manifest = json.loads((self.root / "RELEASE-MANIFEST.json").read_text()) + evidence = self.root / "review-evidence.json" + evidence.write_text(json.dumps(manifest["review_evidence"])) + (self.root / "RELEASE-MANIFEST.json").unlink() + create_manifest(types.SimpleNamespace( + bundle=str(self.root), repository=REPOSITORY, tag=TAG, source_sha=SHA, + source_tree=TREE, version=VERSION, build=BUILD, workflow_id=WORKFLOW_ID, + run_id=RUN_ID, run_attempt=1, evidence=str(evidence), + )) + evidence.unlink() + verify(self.root) + + def test_accepts_exact_prepared_and_public_bundles(self): + verify(self.root) + (self.root / "RELEASE-NOTES.md").unlink() + verify(self.root, public=True) + + def test_rejects_changed_or_malformed_bundles(self): + cases = { + "changed DMG": lambda: (self.root / "Flowlight.dmg").write_bytes(b"tampered"), + "changed PKG": lambda: (self.root / f"Flowlight-{VERSION}.pkg").write_bytes(b"tampered"), + "changed notes": lambda: (self.root / "RELEASE-NOTES.md").write_bytes(b"tampered"), + "changed checksums": lambda: (self.root / "SHA256SUMS.txt").write_text("0" * 64 + " Flowlight.dmg\n"), + "missing file": lambda: (self.root / "Flowlight.dmg").unlink(), + "extra file": lambda: (self.root / "extra").write_text("extra"), + "nested directory": lambda: (self.root / "nested").mkdir(), + "symlink": lambda: (self.root / "extra").symlink_to(self.root / "Flowlight.dmg"), + "duplicate checksum": self._duplicate_checksum, + "uppercase checksum": self._uppercase_checksum, + "manifest wrong repository": lambda: self._manifest_field("repository", "other/repo"), + "manifest wrong tag": lambda: self._manifest_field("tag", "v9.9.9"), + "manifest wrong SHA": lambda: self._manifest_field("source_sha", "d" * 40), + "manifest wrong version": lambda: self._manifest_field("version", "9.9.9"), + "manifest wrong build": lambda: self._manifest_field("build", "999"), + "manifest wrong workflow": lambda: self._preparation_field("workflow", ".github/workflows/ci.yml"), + "manifest wrong run": lambda: self._preparation_field("run_id", 999), + "manifest wrong attempt": lambda: self._preparation_field("run_attempt", 2), + "manifest incomplete evidence": self._incomplete_evidence, + "manifest evidence wrong repository": lambda: self._evidence_field("repository", "other/repo"), + "manifest evidence wrong SHA": lambda: self._evidence_field("source_sha", "d" * 40), + "manifest evidence missing PR": lambda: self._evidence_field("pull_requests", []), + "manifest evidence missing receipt": lambda: self._evidence_field("review_receipts", []), + "manifest receipt wrong PR": lambda: self._review_receipt_field(0, "pull_request", 999), + "manifest receipt invalid ID": lambda: self._review_receipt_field(0, "comment_id", 0), + "manifest receipt missing reviewer": lambda: self._review_receipt_field(0, "reviewer", ""), + "manifest receipt invalid association": lambda: self._review_receipt_field(0, "author_association", "NONE"), + "manifest receipt wrong SHA": lambda: self._review_receipt_field(0, "reviewed_commit", "d" * 40), + "manifest receipt wrong URL": lambda: self._review_receipt_field( + 0, "url", f"https://github.com/{REPOSITORY}/pull/82#issuecomment-9002" + ), + "manifest duplicate receipt": self._duplicate_review_receipt, + "manifest evidence wrong workflow": lambda: self._review_check_field(0, "workflow", ".github/workflows/release.yml"), + "manifest evidence wrong workflow run": lambda: self._review_check_field(0, "workflow_run_id", 999), + "manifest evidence wrong check PR": lambda: self._review_check_field(0, "pull_request", 999), + "manifest evidence wrong check URL": lambda: self._review_check_field(0, "details_url", f"https://github.com/{REPOSITORY}/actions/runs/999"), + "manifest size mismatch": self._size_mismatch, + "manifest checksum disagreement": self._manifest_checksum_disagreement, + } + for label, mutation in cases.items(): + with self.subTest(label=label): + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + write_bundle(root) + old_root, self.root = self.root, root + try: + mutation() + with self.assertRaises(ReleaseArtifactError): + verify(self.root) + finally: + self.root = old_root + + def _load_manifest(self): + return json.loads((self.root / "RELEASE-MANIFEST.json").read_text()) + + def _save_manifest(self, value): + (self.root / "RELEASE-MANIFEST.json").write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + + def _manifest_field(self, key, value): + manifest = self._load_manifest() + manifest[key] = value + self._save_manifest(manifest) + + def _preparation_field(self, key, value): + manifest = self._load_manifest() + manifest["preparation"][key] = value + self._save_manifest(manifest) + + def _incomplete_evidence(self): + manifest = self._load_manifest() + manifest["review_evidence"]["checks"].pop() + self._save_manifest(manifest) + + def _evidence_field(self, key, value): + manifest = self._load_manifest() + manifest["review_evidence"][key] = value + self._save_manifest(manifest) + + def _review_check_field(self, index, key, value): + manifest = self._load_manifest() + manifest["review_evidence"]["checks"][index][key] = value + self._save_manifest(manifest) + + def _review_receipt_field(self, index, key, value): + manifest = self._load_manifest() + manifest["review_evidence"]["review_receipts"][index][key] = value + self._save_manifest(manifest) + + def _duplicate_review_receipt(self): + manifest = self._load_manifest() + manifest["review_evidence"]["review_receipts"].append( + copy.deepcopy(manifest["review_evidence"]["review_receipts"][0]) + ) + self._save_manifest(manifest) + + def _size_mismatch(self): + manifest = self._load_manifest() + manifest["assets"][0]["size"] += 1 + self._save_manifest(manifest) + + def _manifest_checksum_disagreement(self): + manifest = self._load_manifest() + manifest["assets"][0]["sha256"] = "e" * 64 + self._save_manifest(manifest) + + def _duplicate_checksum(self): + line = (self.root / "SHA256SUMS.txt").read_text().splitlines()[0] + (self.root / "SHA256SUMS.txt").write_text(f"{line}\n{line}\n") + + def _uppercase_checksum(self): + path = self.root / "SHA256SUMS.txt" + path.write_text(path.read_text().upper()) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_release_checks.py b/scripts/test_release_checks.py index dccb111c..c8e94ad1 100644 --- a/scripts/test_release_checks.py +++ b/scripts/test_release_checks.py @@ -1,28 +1,61 @@ #!/usr/bin/env python3 import copy +import importlib.util +import pathlib +import sys import unittest -from check_release_checks import REQUIRED_CHECKS, verify - +SCRIPT = pathlib.Path(__file__).with_name("check_release_checks.py") +SPEC = importlib.util.spec_from_file_location("check_release_checks", SCRIPT) +assert SPEC and SPEC.loader +check_release_checks = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = check_release_checks +SPEC.loader.exec_module(check_release_checks) +EXPECTED_ACTIONS_WORKFLOWS = check_release_checks.EXPECTED_ACTIONS_WORKFLOWS +REQUIRED_CHECKS = check_release_checks.REQUIRED_CHECKS +verify = check_release_checks.verify REPO = "xinbetween/flowlight" SHA = "a" * 40 +TRUSTED_REVIEWER = "release-reviewer" -def successful_checks(): - runs = [] +def successful_evidence(): + checks = [] + workflow_runs = [] for index, (app, name) in enumerate(REQUIRED_CHECKS, start=1): - path = "actions/runs/1/job/2" if app == "github-actions" else "runs/3" - runs.append({ + if app == "github-actions": + run_id = 100 + index + details = f"https://github.com/{REPO}/actions/runs/{run_id}/job/{1_000 + index}" + workflow_runs.append({ + "id": run_id, + "path": EXPECTED_ACTIONS_WORKFLOWS[(app, name)], + "head_sha": SHA, + "event": "pull_request", + "status": "completed", + "conclusion": "success", + "repository": {"full_name": REPO}, + "pull_requests": [{ + "number": 76, + "head": {"sha": SHA}, + "base": {"ref": "main"}, + }], + }) + else: + details = f"https://github.com/{REPO}/runs/3" + checks.append({ "id": index, "name": name, "status": "completed", "conclusion": "success", - "details_url": f"https://github.com/{REPO}/{path}", + "details_url": details, "app": {"slug": app}, }) - return [{"total_count": len(runs), "check_runs": runs}] + return ( + [{"total_count": len(checks), "check_runs": checks}], + [{"total_count": len(workflow_runs), "workflow_runs": workflow_runs}], + ) def reviewed_pull(**changes): @@ -32,26 +65,116 @@ def reviewed_pull(**changes): "merged_at": None, "base": {"ref": "main"}, "head": {"sha": SHA}, + "body": f"A pull request author can write this and it is not evidence:\nReviewed-Commit: {SHA}\n", } pull.update(changes) return [[pull]] +def review_comment(**changes): + comment = { + "id": 9_001, + "issue_url": f"https://api.github.com/repos/{REPO}/issues/76", + "html_url": f"https://github.com/{REPO}/pull/76#issuecomment-9001", + "author_association": "OWNER", + "user": {"login": TRUSTED_REVIEWER}, + "body": f"Independent review complete.\n\nReviewed-Commit: {SHA}\n", + } + comment.update(changes) + return [[comment]] + + +def run_verify(checks=None, pulls=None, runs=None, comments=None, reviewers=TRUSTED_REVIEWER): + default_checks, default_runs = successful_evidence() + return verify( + REPO, + SHA, + checks or default_checks, + pulls or reviewed_pull(), + runs or default_runs, + review_comment() if comments is None else comments, + reviewers, + ) + + class ReleaseCheckTests(unittest.TestCase): def test_accepts_exact_reviewed_commit_with_all_checks(self): - errors, pulls = verify(REPO, SHA, successful_checks(), reviewed_pull()) + errors, pulls, receipts = run_verify() self.assertEqual(errors, []) self.assertEqual(pulls, [76]) + self.assertEqual(receipts, [{ + "pull_request": 76, + "comment_id": 9_001, + "reviewer": TRUSTED_REVIEWER, + "author_association": "OWNER", + "reviewed_commit": SHA, + "url": f"https://github.com/{REPO}/pull/76#issuecomment-9001", + }]) + + def test_accepts_private_membership_reported_as_contributor(self): + errors, _, receipts = run_verify( + comments=review_comment(author_association="CONTRIBUTOR") + ) + self.assertEqual(errors, []) + self.assertEqual(receipts[0]["reviewer"], TRUSTED_REVIEWER) + self.assertEqual(receipts[0]["author_association"], "CONTRIBUTOR") def test_accepts_merged_pull(self): pulls = reviewed_pull(state="closed", merged_at="2026-11-04T00:00:00Z") - errors, _ = verify(REPO, SHA, successful_checks(), pulls) + errors, _, _ = run_verify(pulls=pulls) self.assertEqual(errors, []) + def test_pr_author_body_marker_is_not_review_evidence(self): + errors, _, _ = run_verify(comments=[]) + self.assertTrue(any("no authenticated exact-head review comment" in error for error in errors)) + self.assertFalse(any(TRUSTED_REVIEWER in error for error in errors), + "trusted reviewer configuration must not be written to logs") + self.assertFalse(any(SHA in error for error in errors), + "the exact review marker must not be written to logs") + + def test_rejects_spoofed_or_malformed_review_comments(self): + cases = { + "untrusted commenter": review_comment(user={"login": "pull-author"}), + "wrong SHA": review_comment(body=f"Reviewed-Commit: {'b' * 40}"), + "wrong pull": review_comment( + issue_url=f"https://api.github.com/repos/{REPO}/issues/77", + html_url=f"https://github.com/{REPO}/pull/77#issuecomment-9001", + ), + "wrong repository": review_comment( + issue_url="https://api.github.com/repos/other/repo/issues/76", + html_url="https://github.com/other/repo/pull/76#issuecomment-9001", + ), + "missing association metadata": review_comment(author_association=None), + "marker not on its own line": review_comment(body=f"Approved Reviewed-Commit: {SHA}"), + "comment URL wrong pull": review_comment( + html_url=f"https://github.com/{REPO}/pull/77#issuecomment-9001" + ), + "comment URL wrong ID": review_comment( + html_url=f"https://github.com/{REPO}/pull/76#issuecomment-9002" + ), + "missing comment ID": review_comment(id=None), + "missing reviewer": review_comment(user=None), + } + for label, comments in cases.items(): + with self.subTest(label=label): + errors, _, receipts = run_verify(comments=comments) + self.assertEqual(receipts, []) + self.assertTrue(any("no authenticated exact-head review comment" in error for error in errors)) + + def test_requires_a_configured_trusted_reviewer(self): + errors, _, receipts = run_verify(reviewers="") + self.assertEqual(receipts, []) + self.assertTrue(any("no trusted release reviewers" in error for error in errors)) + + def test_rejects_duplicate_review_receipts(self): + duplicate = review_comment()[0][0] + errors, _, _ = run_verify(comments=[[duplicate, copy.deepcopy(duplicate)]]) + self.assertTrue(any("duplicate comment IDs" in error for error in errors)) + def test_rejects_missing_check(self): - checks = successful_checks() + checks, runs = successful_evidence() checks[0]["check_runs"].pop() - errors, _ = verify(REPO, SHA, checks, reviewed_pull()) + errors, _, _ = run_verify(checks=checks, runs=runs) self.assertTrue(any("missing required check" in error for error in errors)) def test_requires_stable_codeql_gate_instead_of_optional_analyzers(self): @@ -59,49 +182,78 @@ def test_requires_stable_codeql_gate_instead_of_optional_analyzers(self): for language in ("swift", "python", "actions"): self.assertNotIn(("github-actions", f"analyze ({language})"), REQUIRED_CHECKS) - checks = successful_checks() + checks, runs_document = successful_evidence() runs = checks[0]["check_runs"] runs[:] = [run for run in runs if run["name"] != "CodeQL / gate"] for language in ("swift", "python", "actions"): runs.append({ - "id": 100 + len(runs), + "id": 200 + len(runs), "name": f"analyze ({language})", "status": "completed", "conclusion": "success", "details_url": f"https://github.com/{REPO}/actions/runs/1/job/{language}", "app": {"slug": "github-actions"}, }) - errors, _ = verify(REPO, SHA, checks, reviewed_pull()) - self.assertTrue(any("missing required check: CodeQL / gate" in error for error in errors)) + errors, _, _ = run_verify(checks=checks, runs=runs_document) + self.assertTrue(any("CodeQL / gate" in error for error in errors)) def test_newer_failure_cannot_hide_behind_older_success(self): - checks = successful_checks() + checks, runs = successful_evidence() failed = copy.deepcopy(checks[0]["check_runs"][0]) failed.update(id=999, status="completed", conclusion="failure") checks[0]["check_runs"].append(failed) - errors, _ = verify(REPO, SHA, checks, reviewed_pull()) + errors, _, _ = run_verify(checks=checks, runs=runs) self.assertTrue(any("not successful" in error for error in errors)) + def test_rejects_same_named_check_from_wrong_workflow(self): + checks, runs = successful_evidence() + first_url = checks[0]["check_runs"][0]["details_url"] + run_id = int(first_url.split("/actions/runs/")[1].split("/")[0]) + target = next(run for run in runs[0]["workflow_runs"] if run["id"] == run_id) + target["path"] = ".github/workflows/spoof.yml" + errors, _, _ = run_verify(checks=checks, runs=runs) + self.assertTrue(any("missing required check from its trusted workflow" in error for error in errors)) + + def test_rejects_workflow_run_for_another_event_or_sha(self): + for field, value in (("event", "workflow_dispatch"), ("head_sha", "b" * 40)): + with self.subTest(field=field): + checks, runs = successful_evidence() + runs[0]["workflow_runs"][0][field] = value + errors, _, _ = run_verify(checks=checks, runs=runs) + self.assertTrue(any("missing required check from its trusted workflow" in error for error in errors)) + + def test_rejects_checks_from_another_pr_at_the_same_commit(self): + cases = { + "another pull request": {"number": 77, "head": {"sha": SHA}, "base": {"ref": "main"}}, + "another base": {"number": 76, "head": {"sha": SHA}, "base": {"ref": "develop"}}, + "another head": {"number": 76, "head": {"sha": "b" * 40}, "base": {"ref": "main"}}, + } + for label, linked_pull in cases.items(): + with self.subTest(label=label): + checks, runs = successful_evidence() + for run in runs[0]["workflow_runs"]: + run["pull_requests"] = [linked_pull] + errors, _, _ = run_verify(checks=checks, runs=runs) + self.assertTrue(any("missing required check from its trusted workflow" in error for error in errors)) + def test_rejects_check_from_untrusted_app(self): - checks = successful_checks() + checks, runs = successful_evidence() checks[0]["check_runs"][0]["app"]["slug"] = "untrusted" - errors, _ = verify(REPO, SHA, checks, reviewed_pull()) + errors, _, _ = run_verify(checks=checks, runs=runs) self.assertTrue(any("missing required check" in error for error in errors)) def test_rejects_check_linked_to_another_repository(self): - checks = successful_checks() + checks, runs = successful_evidence() checks[0]["check_runs"][0]["details_url"] = "https://github.com/other/repo/actions/runs/1" - errors, _ = verify(REPO, SHA, checks, reviewed_pull()) + errors, _, _ = run_verify(checks=checks, runs=runs) self.assertTrue(any("missing required check" in error for error in errors)) def test_rejects_wrong_pull_head(self): - pulls = reviewed_pull(head={"sha": "b" * 40}) - errors, _ = verify(REPO, SHA, successful_checks(), pulls) + errors, _, _ = run_verify(pulls=reviewed_pull(head={"sha": "b" * 40})) self.assertTrue(any("no open or merged pull request" in error for error in errors)) def test_rejects_closed_unmerged_pull(self): - pulls = reviewed_pull(state="closed", merged_at=None) - errors, _ = verify(REPO, SHA, successful_checks(), pulls) + errors, _, _ = run_verify(pulls=reviewed_pull(state="closed", merged_at=None)) self.assertTrue(any("no open or merged pull request" in error for error in errors)) diff --git a/scripts/test_release_workflow.py b/scripts/test_release_workflow.py new file mode 100755 index 00000000..e34c3e54 --- /dev/null +++ b/scripts/test_release_workflow.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Static fail-closed contract for the prepare/publish privilege split.""" + +import pathlib +import re +import unittest + +WORKFLOW = pathlib.Path(__file__).resolve().parents[1] / ".github/workflows/release.yml" +CI_WORKFLOW = pathlib.Path(__file__).resolve().parents[1] / ".github/workflows/ci.yml" +FORBIDDEN_PUBLICATION = ( + "APP_CERTIFICATE_P12", "INSTALLER_CERTIFICATE_P12", "CERTIFICATE_PASSWORD", + "APP_PROVISIONING_PROFILE", "EXT_PROVISIONING_PROFILE", "APP_PROFILE", "EXT_PROFILE", + "NOTARY_", "TEAM_ID", "DMG_SIGN_IDENTITY", "INSTALLER_IDENTITY", + "security ", "xcodebuild", "xcrun ", "build-signed.sh", "build-dmg.sh", "build-pkg.sh", + "notarize.sh", "codesign", "productbuild", "productsign", "notarytool", +) + + +def job_block(text, name, next_name=None): + start = text.index(f" {name}:\n") + end = text.index(f" {next_name}:\n", start) if next_name else len(text) + return text[start:end] + + +def permissions(block): + lines = block.splitlines() + start = lines.index(" permissions:") + 1 + result = {} + for line in lines[start:]: + match = re.fullmatch(r" ([a-z-]+): (read|write|none)", line) + if not match: + break + result[match.group(1)] = match.group(2) + return result + + +def run_scripts(block): + lines = block.splitlines() + scripts = [] + index = 0 + while index < len(lines): + if lines[index] == " run: |": + index += 1 + body = [] + while index < len(lines) and (not lines[index] or lines[index].startswith(" ")): + body.append(lines[index][10:] if lines[index].startswith(" ") else "") + index += 1 + scripts.append("\n".join(body)) + else: + index += 1 + return scripts + + +def forbidden_publication_values(block): + return [value for value in FORBIDDEN_PUBLICATION if value in block] + + +class ReleaseWorkflowContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.workflow = WORKFLOW.read_text() + cls.ci_workflow = CI_WORKFLOW.read_text() + cls.prepare = job_block(cls.workflow, "prepare", "publish") + cls.publish = job_block(cls.workflow, "publish") + cls.merge_compatibility = job_block(cls.ci_workflow, "merge-compatibility", "gate") + + def test_manual_publication_requires_preparation_run(self): + self.assertRegex(self.workflow, r"(?m)^ prepare_run_id:\n") + self.assertIn("github.event_name == 'push'", self.prepare) + self.assertIn("github.event_name == 'workflow_dispatch'", self.publish) + + def test_preparation_cannot_publish(self): + self.assertEqual(permissions(self.prepare), { + "contents": "read", "checks": "read", "issues": "read", "pull-requests": "read", + "actions": "read", "id-token": "write", "attestations": "write", + }) + self.assertNotIn("gh release", self.prepare) + self.assertIn("actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02", self.prepare) + self.assertIn("if-no-files-found: error", self.prepare) + self.assertIn("retention-days: 90", self.prepare) + + def test_preparation_requires_authenticated_review_comments(self): + self.assertIn("RELEASE_REVIEWERS", self.prepare) + self.assertIn('issues/$pull_number/comments?per_page=100', self.prepare) + self.assertNotIn('issues/comments?per_page=100', self.prepare) + self.assertIn('for attempt in 1 2 3 4 5 6', self.prepare) + self.assertIn('select(.head.sha == $sha and .base.ref == "main")', self.prepare) + self.assertIn('--comments "$COMMENTS"', self.prepare) + self.assertIn('--reviewers "$RELEASE_REVIEWERS"', self.prepare) + self.assertNotIn("pull request whose body", self.prepare) + + def test_review_lookup_contract_rejects_repository_wide_comment_scan(self): + tampered = self.prepare.replace( + 'issues/$pull_number/comments?per_page=100', + 'issues/comments?per_page=100', + ) + self.assertNotIn('issues/$pull_number/comments?per_page=100', tampered) + self.assertIn('issues/comments?per_page=100', tampered) + + def test_merge_compatibility_uses_pull_request_default_merge_ref(self): + self.assertIn("actions/checkout@", self.merge_compatibility) + self.assertNotIn("refs/pull/", self.merge_compatibility) + self.assertNotRegex(self.merge_compatibility, r"(?m)^ ref:") + + def test_untrusted_checkout_detector_rejects_deliberate_ref_fixture(self): + unsafe = self.merge_compatibility.replace( + " - uses: actions/checkout@", + " - uses: actions/checkout@", + ) + "\n with:\n ref: refs/pull/${{ github.event.number }}/merge\n" + self.assertIn("refs/pull/", unsafe) + self.assertRegex(unsafe, r"(?m)^ ref:") + + def test_publication_has_only_distribution_permissions(self): + self.assertEqual(permissions(self.publish), { + "actions": "read", "attestations": "read", "contents": "write", + }) + self.assertNotRegex(self.publish, r"(?m)^ environment: release$") + self.assertIn("persist-credentials: false", self.publish) + + def test_shell_scripts_never_interpolate_actions_expressions(self): + declarations = re.findall(r"(?m)^\s+run:", self.workflow) + self.assertEqual(len(run_scripts(self.workflow)), len(declarations), "every shell step must use a literal run: | block") + for script in run_scripts(self.workflow): + self.assertNotIn("${{", script) + self.assertIn("github.triggering_actor", self.prepare) + self.assertIn("github.triggering_actor", self.publish) + self.assertNotIn("TAP_TOKEN", self.publish[:self.publish.index("- name: Update Homebrew")]) + self.assertLess(self.publish.index("- name: Update Homebrew"), self.publish.index("TAP_TOKEN:")) + self.assertNotIn("x-access-token", self.publish) + self.assertIn('export GH_TOKEN="$TAP_TOKEN"', self.publish) + + def test_expression_detector_rejects_deliberate_input_injection_fixture(self): + unsafe = " run: |\n run_id=\"${{ inputs.prepare_run_id }}\"\n" + scripts = run_scripts(unsafe) + self.assertEqual(len(scripts), 1) + self.assertIn("${{", scripts[0]) + + def test_publication_cannot_build_sign_or_notarize(self): + self.assertEqual(forbidden_publication_values(self.publish), []) + + def test_forbidden_command_detector_rejects_deliberate_tamper(self): + self.assertEqual(forbidden_publication_values(self.publish + "\nxcodebuild archive\n"), ["xcodebuild"]) + + def test_archive_provenance_and_draft_bytes_are_ordered_before_publication(self): + digest = self.publish.index('ACTUAL="sha256:$(sha256sum') + extract = self.publish.index("release_artifact.py extract-archive") + bundle = self.publish.index("verify-bundle") + attest = self.publish.index("gh attestation verify") + create = self.publish.index("gh release create") + download = self.publish.index("gh release download") + compare = self.publish.index('cmp "$PREPARED/Flowlight.dmg"') + publish = self.publish.index('gh release edit "$TAG" --draft=false') + self.assertLess(digest, extract) + self.assertLess(extract, bundle) + self.assertLess(bundle, attest) + self.assertLess(attest, create) + self.assertLess(create, download) + self.assertLess(download, compare) + self.assertLess(compare, publish) + for value in ( + 'cmp "$PREPARED/Flowlight-$VERSION.pkg"', + 'cmp "$PREPARED/SHA256SUMS.txt"', + 'cmp "$PREPARED/RELEASE-MANIFEST.json"', + "release body differs from the prepared release notes", + ): + self.assertIn(value, self.publish) + + def test_publication_is_resumable_without_mutating_public_assets(self): + self.assertIn("Resuming the existing draft", self.publish) + self.assertIn("public release already exists", self.publish) + self.assertIn("it will be verified without mutation", self.publish) + self.assertIn("gh release upload", self.publish) + self.assertIn("--clobber", self.publish) + + def test_homebrew_requires_the_verified_local_dmg(self): + self.assertIn("REQUIRE_LOCAL_DMG=1", self.publish) + self.assertNotRegex(self.publish, r"update-cask\.sh[^\n]*https?://") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_workflow_evidence.py b/scripts/test_workflow_evidence.py new file mode 100644 index 00000000..0fce1dd8 --- /dev/null +++ b/scripts/test_workflow_evidence.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +"""Adversarial fixtures for CI reuse and trusted Pages provenance.""" + +from __future__ import annotations + +import copy +import datetime as dt +import hashlib +import importlib.util +import io +import json +import pathlib +import stat +import sys +import tarfile +import tempfile +import unittest + + +SCRIPT = pathlib.Path(__file__).with_name("workflow_evidence.py") +SPEC = importlib.util.spec_from_file_location("workflow_evidence", SCRIPT) +assert SPEC and SPEC.loader +workflow_evidence = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = workflow_evidence +SPEC.loader.exec_module(workflow_evidence) + +REPO = "xinbetween/flowlight" +SHA = "a" * 40 +OTHER_SHA = "b" * 40 +TREE = "c" * 40 +OTHER_TREE = "d" * 40 +FINGERPRINT = "e" * 64 +ARCHIVE_DIGEST = "sha256:" + "f" * 64 +NOW = dt.datetime(2026, 11, 4, tzinfo=dt.timezone.utc) + + +def run_fixture(**changes): + run = { + "id": 101, + "run_attempt": 2, + "repository": {"full_name": REPO}, + "head_repository": {"full_name": REPO}, + "path": ".github/workflows/ci.yml", + "event": "pull_request", + "head_sha": SHA, + "status": "completed", + "conclusion": "success", + "pull_requests": [{ + "number": 17, + "head": {"ref": "release/0.17.5", "sha": SHA, "repo": {"full_name": REPO}}, + "base": {"ref": "main", "sha": OTHER_SHA, "repo": {"full_name": REPO}}, + }], + } + run.update(changes) + return run + + +def artifact_fixture(**changes): + artifact = { + "id": 501, + "name": f"review-receipt-{SHA}-2", + "expired": False, + "expires_at": "2026-12-04T00:00:00Z", + "workflow_run": {"id": 101}, + "digest": ARCHIVE_DIGEST, + "size_in_bytes": 1234, + } + artifact.update(changes) + return artifact + + +def receipt_fixture(**changes): + receipt = { + "schema": "flowlight-review-v3", + "repository": REPO, + "workflow": ".github/workflows/ci.yml", + "event": "pull_request", + "source_sha": SHA, + "source_tree": TREE, + "app_fingerprint": FINGERPRINT, + "app_route": True, + "conclusions": dict(workflow_evidence.REQUIRED_CONCLUSIONS), + "run_id": 101, + "run_attempt": 2, + "app_evidence": {"mode": "executed", "source": None}, + } + receipt.update(changes) + return receipt + + +def expectation(**changes): + values = { + "repository": REPO, + "source_sha": OTHER_SHA, + "source_tree": TREE, + "app_fingerprint": FINGERPRINT, + "current_run_id": 999, + } + values.update(changes) + return workflow_evidence.ReuseExpectation(**values) + + +class ReuseEvidenceTests(unittest.TestCase): + def validate(self, run=None, artifact=None, receipt=None, expected=None, digest=ARCHIVE_DIGEST): + return workflow_evidence.validate_reuse_evidence( + run or run_fixture(), + artifact or artifact_fixture(), + receipt or receipt_fixture(), + expected or expectation(), + digest, + now=NOW, + ) + + def test_accepts_only_direct_exact_tree_evidence(self): + self.assertEqual(self.validate(), { + "run_id": 101, + "run_attempt": 2, + "artifact_id": 501, + "artifact_name": f"review-receipt-{SHA}-2", + "artifact_digest": ARCHIVE_DIGEST, + }) + + def test_rejects_wrong_run_trust_identity_and_state(self): + cases = { + "repository": run_fixture(repository={"full_name": "other/repo"}), + "fork head": run_fixture(head_repository={"full_name": "contributor/flowlight"}), + "workflow": run_fixture(path=".github/workflows/spoof.yml"), + "event": run_fixture(event="push"), + "status": run_fixture(status="in_progress"), + "conclusion": run_fixture(conclusion="failure"), + "source SHA": run_fixture(head_sha="not-a-sha"), + "run ID": run_fixture(id=0), + "run attempt": run_fixture(run_attempt=0), + "no associated PR": run_fixture(pull_requests=[]), + "ambiguous associated PRs": run_fixture( + pull_requests=run_fixture()["pull_requests"] * 2 + ), + "alternate base": run_fixture(pull_requests=[{ + "number": 17, + "head": {"ref": "release/0.17.5", "sha": SHA, "repo": {"full_name": REPO}}, + "base": {"ref": "attacker-base", "sha": OTHER_SHA, "repo": {"full_name": REPO}}, + }]), + "foreign base repository": run_fixture(pull_requests=[{ + "number": 17, + "head": {"ref": "release/0.17.5", "sha": SHA, "repo": {"full_name": REPO}}, + "base": {"ref": "main", "sha": OTHER_SHA, + "repo": {"full_name": "collaborator/flowlight"}}, + }]), + "mismatched PR head": run_fixture(pull_requests=[{ + "number": 17, + "head": {"ref": "release/0.17.5", "sha": OTHER_SHA, + "repo": {"full_name": REPO}}, + "base": {"ref": "main", "sha": OTHER_SHA, "repo": {"full_name": REPO}}, + }]), + } + for label, run in cases.items(): + with self.subTest(label=label), self.assertRaises(workflow_evidence.EvidenceError): + self.validate(run=run) + + def test_rejects_wrong_receipt_identity(self): + cases = { + "repository": receipt_fixture(repository="other/repo"), + "workflow": receipt_fixture(workflow=".github/workflows/spoof.yml"), + "event": receipt_fixture(event="push"), + "SHA": receipt_fixture(source_sha=OTHER_SHA), + "tree": receipt_fixture(source_tree=OTHER_TREE), + "fingerprint": receipt_fixture(app_fingerprint="0" * 64), + "schema": receipt_fixture(schema="flowlight-review-v2"), + "run ID": receipt_fixture(run_id=102), + "attempt": receipt_fixture(run_attempt=1), + "route": receipt_fixture(app_route=False), + "chained reuse": receipt_fixture(app_evidence={ + "mode": "reused", + "source": {"run_id": 1}, + }), + } + for label, receipt in cases.items(): + with self.subTest(label=label), self.assertRaises(workflow_evidence.EvidenceError): + self.validate(receipt=receipt) + + def test_rejects_any_required_job_conclusion(self): + for name in workflow_evidence.REQUIRED_CONCLUSIONS: + with self.subTest(name=name): + conclusions = dict(workflow_evidence.REQUIRED_CONCLUSIONS) + conclusions[name] = "failure" + with self.assertRaises(workflow_evidence.EvidenceError): + self.validate(receipt=receipt_fixture(conclusions=conclusions)) + + def test_rejects_artifact_id_digest_run_name_and_expiry_mismatch(self): + cases = { + "ID": (artifact_fixture(id=0), ARCHIVE_DIGEST), + "metadata digest": (artifact_fixture(digest="sha256:" + "0" * 64), ARCHIVE_DIGEST), + "download digest": (artifact_fixture(), "sha256:" + "0" * 64), + "run": (artifact_fixture(workflow_run={"id": 999}), ARCHIVE_DIGEST), + "name": (artifact_fixture(name="review-receipt-wrong"), ARCHIVE_DIGEST), + "expired flag": (artifact_fixture(expired=True), ARCHIVE_DIGEST), + "expired time": (artifact_fixture(expires_at="2026-11-03T00:00:00Z"), ARCHIVE_DIGEST), + } + for label, (artifact, digest) in cases.items(): + with self.subTest(label=label), self.assertRaises(workflow_evidence.EvidenceError): + self.validate(artifact=artifact, digest=digest) + + def test_newer_failure_blocks_an_older_success(self): + old = run_fixture(id=100, run_attempt=1, conclusion="success") + newer = run_fixture(id=102, run_attempt=1, conclusion="failure") + selected = workflow_evidence.select_exact_tree_run( + [old, newer], {SHA: TREE}, expectation() + ) + self.assertEqual(selected["id"], 102) + with self.assertRaises(workflow_evidence.EvidenceError): + self.validate(run=selected) + + def test_matching_fingerprint_cannot_reuse_a_different_full_tree(self): + selected = workflow_evidence.select_exact_tree_run( + [run_fixture()], {SHA: OTHER_TREE}, expectation() + ) + self.assertIsNone(selected) + with self.assertRaises(workflow_evidence.EvidenceError): + self.validate(receipt=receipt_fixture(source_tree=OTHER_TREE)) + + +class PagesSourceTests(unittest.TestCase): + def source(self, **changes): + run = { + "id": 301, + "run_attempt": 1, + "repository": {"full_name": REPO}, + "head_repository": {"full_name": REPO}, + "path": ".github/workflows/ci.yml", + "event": "push", + "head_branch": "main", + "head_sha": SHA, + "status": "completed", + "conclusion": "success", + } + run.update(changes) + return run + + def test_accepts_current_canonical_main_ci_push(self): + self.assertEqual( + workflow_evidence.validate_pages_source(self.source(), repository=REPO, current_main_sha=SHA)["id"], + 301, + ) + + def test_rejects_wrong_branch_event_run_status_source_workflow_and_repository(self): + cases = { + "branch": self.source(head_branch="release/0.17.5"), + "PR artifact": self.source(event="pull_request"), + "run": self.source(id=0), + "status": self.source(status="in_progress"), + "conclusion": self.source(conclusion="failure"), + "source": self.source(head_sha="bad"), + "workflow": self.source(path=".github/workflows/pages.yml"), + "repository": self.source(repository={"full_name": "other/repo"}), + "fork": self.source(head_repository={"full_name": "fork/flowlight"}), + } + for label, run in cases.items(): + with self.subTest(label=label), self.assertRaises(workflow_evidence.EvidenceError): + workflow_evidence.validate_pages_source(run, repository=REPO, current_main_sha=SHA) + + def test_rejects_stale_source_when_main_advanced(self): + with self.assertRaisesRegex(workflow_evidence.EvidenceError, "stale"): + workflow_evidence.validate_pages_source(self.source(), repository=REPO, current_main_sha=OTHER_SHA) + + +class PagesBundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.base = pathlib.Path(self.temp.name) + self.docs = self.base / "docs" + self.docs.mkdir() + (self.docs / "index.html").write_text("home\n") + assets = self.docs / "assets" + assets.mkdir() + (assets / "site.css").write_text("body{}\n") + self.bundle = self.base / "pages.tar" + workflow_evidence.create_pages_bundle(self.docs, self.bundle, REPO, SHA, TREE) + + def tearDown(self): + self.temp.cleanup() + + def verify(self, bundle=None): + return workflow_evidence.verify_pages_bundle( + bundle or self.bundle, + self.base / f"out-{len(list(self.base.glob('out-*')))}", + repository=REPO, + source_sha=SHA, + source_tree=TREE, + ) + + def rewrite(self, mutate): + manifest, payload = workflow_evidence._tar_members(self.bundle) + entries = [("manifest.json", (json.dumps(manifest, sort_keys=True) + "\n").encode(), 0o644)] + entries += [(f"site/{path}", data, mode) for path, (data, mode) in payload.items()] + entries = mutate(entries) + target = self.base / f"tampered-{len(list(self.base.glob('tampered-*')))}.tar" + with tarfile.open(target, "w") as archive: + for name, data, mode in entries: + info = tarfile.TarInfo(name) + info.size = len(data) + info.mode = mode + archive.addfile(info, io.BytesIO(data)) + return target + + def test_round_trip_is_deterministic_and_exact(self): + second = self.base / "second.tar" + workflow_evidence.create_pages_bundle(self.docs, second, REPO, SHA, TREE) + self.assertEqual(self.bundle.read_bytes(), second.read_bytes()) + manifest = self.verify() + self.assertEqual([record["path"] for record in manifest["files"]], ["assets/site.css", "index.html"]) + + def test_rejects_manifest_bound_to_another_source_tree(self): + with self.assertRaises(workflow_evidence.EvidenceError): + workflow_evidence.verify_pages_bundle( + self.bundle, + self.base / "wrong-tree-output", + repository=REPO, + source_sha=SHA, + source_tree=OTHER_TREE, + ) + + def test_rejects_missing_extra_and_changed_payload(self): + variants = { + "missing": lambda entries: [entry for entry in entries if entry[0] != "site/index.html"], + "extra": lambda entries: entries + [("site/extra.txt", b"extra", 0o644)], + "changed": lambda entries: [ + (name, b"changed" if name == "site/index.html" else data, mode) + for name, data, mode in entries + ], + } + for label, mutate in variants.items(): + with self.subTest(label=label), self.assertRaises(workflow_evidence.EvidenceError): + self.verify(self.rewrite(mutate)) + + def test_rejects_symlink_and_path_traversal_entries(self): + for label, name, kind in ( + ("traversal", "site/../outside", "file"), + ("symlink", "site/link", "symlink"), + ): + with self.subTest(label=label): + target = self.base / f"{label}.tar" + with tarfile.open(target, "w") as archive: + info = tarfile.TarInfo(name) + if kind == "symlink": + info.type = tarfile.SYMTYPE + info.linkname = "index.html" + archive.addfile(info) + else: + info.size = 1 + archive.addfile(info, io.BytesIO(b"x")) + with self.assertRaises(workflow_evidence.EvidenceError): + self.verify(target) + + def test_bundle_creation_rejects_source_symlink(self): + link = self.docs / "link" + try: + link.symlink_to("index.html") + except OSError: + self.skipTest("symlinks are unavailable") + with self.assertRaises(workflow_evidence.EvidenceError): + workflow_evidence.create_pages_bundle(self.docs, self.base / "bad.tar", REPO, SHA, TREE) + + def test_pages_artifact_metadata_is_bound_to_source_run_and_archive(self): + artifact = artifact_fixture( + id=777, + name=f"pages-site-{SHA}-1", + workflow_run={"id": 301}, + ) + self.assertEqual( + workflow_evidence.validate_artifact( + artifact, + run_id=301, + expected_name=f"pages-site-{SHA}-1", + archive_digest=ARCHIVE_DIGEST, + now=NOW, + )["id"], + 777, + ) + for changed in ( + {"name": "pages-site-wrong"}, + {"workflow_run": {"id": 302}}, + {"digest": "sha256:" + "0" * 64}, + {"id": 0}, + ): + tampered = copy.deepcopy(artifact) + tampered.update(changed) + with self.assertRaises(workflow_evidence.EvidenceError): + workflow_evidence.validate_artifact( + tampered, + run_id=301, + expected_name=f"pages-site-{SHA}-1", + archive_digest=ARCHIVE_DIGEST, + now=NOW, + ) + + +class WorkflowContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + root = pathlib.Path(__file__).resolve().parents[1] + cls.ci = (root / ".github/workflows/ci.yml").read_text() + cls.pages = (root / ".github/workflows/pages.yml").read_text() + + def test_ci_registers_evidence_tests_and_preserves_required_names(self): + self.assertIn("python3 -I scripts/test_workflow_evidence.py", self.ci) + for name in ("unit (oldest Xcode)", "unit (newest Xcode)", "Review / gate"): + self.assertIn(f"name: {name}", self.ci) + self.assertIn("flowlight-review-v3", self.ci) + self.assertIn("ACTUAL_TREE=$(git rev-parse 'HEAD^{tree}')", self.ci) + + def test_change_policy_comes_from_the_trusted_base_commit(self): + start = self.ci.index(" changes:\n") + end = self.ci.index(" evidence:\n", start) + block = self.ci[start:end] + self.assertIn('TRUSTED_POLICY_SHA: ${{ github.event.pull_request.base.sha || github.sha }}', block) + self.assertIn('git show "$TRUSTED_POLICY_SHA:scripts/classify_changes.py"', block) + self.assertIn('python3 -I "$RUNNER_TEMP/classify_changes.py"', block) + self.assertNotIn("python3 -I scripts/classify_changes.py", block) + self.assertIn('"schema=flowlight-review-v3"', block) + + def test_reuse_policy_comes_from_base_and_forks_cannot_reuse(self): + start = self.ci.index(" evidence:\n") + end = self.ci.index(" site:\n", start) + block = self.ci[start:end] + self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", block) + self.assertIn('TRUSTED_VERIFIER_SHA: ${{ github.event.pull_request.base.sha }}', block) + self.assertIn('git show "$TRUSTED_VERIFIER_SHA:scripts/workflow_evidence.py"', block) + self.assertIn('python3 -I "$RUNNER_TEMP/workflow_evidence.py" find-reuse', block) + self.assertNotIn("python3 -I scripts/workflow_evidence.py find-reuse", block) + self.assertIn('echo "reuse=false" >> "$GITHUB_OUTPUT"', block) + + def test_trust_contract_rejects_deliberate_pr_controlled_helpers(self): + unsafe_changes = self.ci.replace( + 'python3 -I "$RUNNER_TEMP/classify_changes.py"', + "python3 -I scripts/classify_changes.py", + 1, + ) + changes = unsafe_changes[unsafe_changes.index(" changes:\n"):unsafe_changes.index(" evidence:\n")] + self.assertIn("python3 -I scripts/classify_changes.py", changes) + self.assertNotIn('python3 -I "$RUNNER_TEMP/classify_changes.py"', changes) + + unsafe_evidence = self.ci.replace( + 'python3 -I "$RUNNER_TEMP/workflow_evidence.py" find-reuse', + "python3 -I scripts/workflow_evidence.py find-reuse", + 1, + ) + evidence = unsafe_evidence[unsafe_evidence.index(" evidence:\n"):unsafe_evidence.index(" site:\n")] + self.assertIn("python3 -I scripts/workflow_evidence.py find-reuse", evidence) + self.assertNotIn('python3 -I "$RUNNER_TEMP/workflow_evidence.py" find-reuse', evidence) + + def test_merge_compatibility_keeps_default_pull_request_checkout(self): + start = self.ci.index(" merge-compatibility:\n") + end = self.ci.index(" gate:\n", start) + block = self.ci[start:end] + self.assertIn("actions/checkout@", block) + self.assertNotIn("refs/pull/", block) + self.assertNotRegex(block, r"(?m)^ ref:") + + def test_pages_is_workflow_run_only_without_checkout_or_rebuild(self): + self.assertIn("workflow_run:", self.pages) + self.assertIn("workflows: [CI]", self.pages) + self.assertNotIn("workflow_dispatch:", self.pages) + self.assertNotIn("actions/checkout@", self.pages) + self.assertNotIn("build_site.py", self.pages) + self.assertIn("consume-pages", self.pages) + self.assertGreaterEqual(self.pages.count("git/ref/heads/main"), 2) + + def test_pages_deploy_job_has_only_deployment_permissions(self): + start = self.pages.index(" deploy:\n") + block = self.pages[start:] + self.assertIn(" pages: write\n id-token: write\n", block) + self.assertNotIn(" contents:", block) + self.assertNotIn(" actions:", block) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/update-cask.sh b/scripts/update-cask.sh index 32c9c175..9982935f 100755 --- a/scripts/update-cask.sh +++ b/scripts/update-cask.sh @@ -1,9 +1,10 @@ -#!/bin/zsh +#!/usr/bin/env bash # Updates packaging/homebrew/flowlight.rb to the current version and the DMG's checksum, then copies it into the # Homebrew tap so `brew install --cask xinbetween/tap/flowlight` gets the new release. # # scripts/update-cask.sh # version from project.yml, checksum from build/Flowlight.dmg # VERSION=0.2.1 scripts/update-cask.sh # no local build: checksum from the published release +# REQUIRE_LOCAL_DMG=1 scripts/update-cask.sh # CI: refuse a fallback download # TAP=/path/to/homebrew-tap scripts/update-cask.sh # # Without TAP it uses the tap Homebrew already has checked out, and only copies the file — pushing is yours to do. @@ -11,20 +12,49 @@ set -euo pipefail cd "$(dirname "$0")/.." VERSION="${VERSION:-$(sed -n 's/.*MARKETING_VERSION:[[:space:]]*"\(.*\)".*/\1/p' project.yml)}" -CASK=packaging/homebrew/flowlight.rb +CASK="${CASK_PATH:-packaging/homebrew/flowlight.rb}" +LOCAL_DMG="${DMG_PATH:-build/Flowlight.dmg}" +sha256_file() { + python3 -I - "$1" <<'PY' +import hashlib +import pathlib +import sys -if [[ -f build/Flowlight.dmg ]]; then - SHA=$(shasum -a 256 build/Flowlight.dmg | cut -d' ' -f1) - echo "Checksum from build/Flowlight.dmg" +value = hashlib.sha256() +with pathlib.Path(sys.argv[1]).open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + value.update(chunk) +print(value.hexdigest()) +PY +} + +if [[ -f "$LOCAL_DMG" ]]; then + SHA=$(sha256_file "$LOCAL_DMG") + echo "Checksum from $LOCAL_DMG" +elif [[ -n "${REQUIRE_LOCAL_DMG:-}" ]]; then + echo "REQUIRE_LOCAL_DMG is set, but $LOCAL_DMG is missing." >&2 + exit 1 else TMP=$(mktemp -d) curl -fsSL -o "$TMP/Flowlight.dmg" "https://github.com/xinbetween/flowlight/releases/download/v$VERSION/Flowlight.dmg" - SHA=$(shasum -a 256 "$TMP/Flowlight.dmg" | cut -d' ' -f1) + SHA=$(sha256_file "$TMP/Flowlight.dmg") rm -rf "$TMP" echo "Checksum from the published v$VERSION DMG" fi -/usr/bin/sed -i '' -e "s/^ version \".*\"/ version \"$VERSION\"/" -e "s/^ sha256 \".*\"/ sha256 \"$SHA\"/" "$CASK" +python3 -I - "$CASK" "$VERSION" "$SHA" <<'PY' +import pathlib +import re +import sys + +path = pathlib.Path(sys.argv[1]) +text = path.read_text() +text, versions = re.subn(r'^ version ".*"$', f' version "{sys.argv[2]}"', text, count=1, flags=re.MULTILINE) +text, checksums = re.subn(r'^ sha256 ".*"$', f' sha256 "{sys.argv[3]}"', text, count=1, flags=re.MULTILINE) +if versions != 1 or checksums != 1: + raise SystemExit("cask must contain exactly one version and one sha256 declaration") +path.write_text(text) +PY echo "$CASK → version $VERSION, sha256 $SHA" # Homebrew refuses to lint a cask outside a tap, so both checks run on the tap's copy. @@ -45,5 +75,10 @@ else fi if [[ -d "$TAP/.git" ]]; then git -C "$TAP" add Casks/flowlight.rb - git -C "$TAP" commit -m "flowlight $VERSION" >/dev/null && echo "Committed in $TAP — push it to publish." + if git -C "$TAP" diff --cached --quiet; then + echo "The tap already has Flowlight $VERSION; no commit needed." + else + git -C "$TAP" commit -m "flowlight $VERSION" >/dev/null + echo "Committed in $TAP — push it to publish." + fi fi diff --git a/scripts/workflow_evidence.py b/scripts/workflow_evidence.py new file mode 100644 index 00000000..449ffc50 --- /dev/null +++ b/scripts/workflow_evidence.py @@ -0,0 +1,648 @@ +#!/usr/bin/env python3 +"""Create and verify CI reuse receipts and trusted Pages payloads.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import io +import json +import pathlib +import re +import stat +import subprocess +import sys +import tarfile +import tempfile +import zipfile +from dataclasses import dataclass +from typing import Any, Iterable + + +REVIEW_SCHEMA = "flowlight-review-v3" +PAGES_SCHEMA = "flowlight-pages-v1" +CI_WORKFLOW = ".github/workflows/ci.yml" +SHA = re.compile(r"[0-9a-f]{40}") +SHA256 = re.compile(r"[0-9a-f]{64}") +REQUIRED_CONCLUSIONS = { + "changes": "success", + "site": "success", + "project_drift": "success", + "unit_oldest": "success", + "unit_newest": "success", + "store": "success", + "merge_compatibility": "success", +} + + +class EvidenceError(ValueError): + """Evidence is absent, malformed, stale, or bound to another identity.""" + + +@dataclass(frozen=True) +class ReuseExpectation: + repository: str + source_sha: str + source_tree: str + app_fingerprint: str + current_run_id: int + workflow: str = CI_WORKFLOW + schema: str = REVIEW_SCHEMA + base_branch: str = "main" + + +def _object(value: Any, label: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise EvidenceError(f"{label} has an unexpected shape") + return value + + +def _positive_int(value: Any, label: str) -> int: + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise EvidenceError(f"{label} is missing or malformed") + return value + + +def _exact_sha(value: Any, label: str) -> str: + if not isinstance(value, str) or not SHA.fullmatch(value): + raise EvidenceError(f"{label} is missing or malformed") + return value + + +def _digest(value: Any, label: str) -> str: + if not isinstance(value, str) or not value.startswith("sha256:") or not SHA256.fullmatch(value[7:]): + raise EvidenceError(f"{label} is missing or malformed") + return value + + +def _timestamp(value: Any, label: str) -> dt.datetime: + if not isinstance(value, str): + raise EvidenceError(f"{label} is missing or malformed") + try: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as error: + raise EvidenceError(f"{label} is missing or malformed") from error + if parsed.tzinfo is None: + raise EvidenceError(f"{label} has no timezone") + return parsed + + +def _safe_path(raw: Any, label: str) -> pathlib.PurePosixPath: + if not isinstance(raw, str) or not raw or raw.startswith("/") or "\\" in raw: + raise EvidenceError(f"{label} is unsafe") + if any(part in {"", ".", ".."} for part in raw.split("/")): + raise EvidenceError(f"{label} is unsafe") + path = pathlib.PurePosixPath(raw) + if path.as_posix() != raw: + raise EvidenceError(f"{label} is not canonical") + return path + + +def _require_fields(document: dict[str, Any], expected: set[str], label: str) -> None: + if set(document) != expected: + raise EvidenceError(f"{label} fields do not match the schema") + + +def trusted_pr_run(run: Any, expected: ReuseExpectation) -> dict[str, Any]: + run = _object(run, "workflow run") + checks = { + "repository": (_object(run.get("repository"), "workflow run repository").get("full_name"), expected.repository), + "workflow": (run.get("path"), expected.workflow), + "event": (run.get("event"), "pull_request"), + "head repository": ( + _object(run.get("head_repository"), "workflow run head repository").get("full_name"), + expected.repository, + ), + } + for label, (actual, wanted) in checks.items(): + if actual != wanted: + raise EvidenceError(f"workflow run {label} is {actual!r}, expected {wanted!r}") + _positive_int(run.get("id"), "workflow run ID") + _positive_int(run.get("run_attempt"), "workflow run attempt") + head_sha = _exact_sha(run.get("head_sha"), "workflow run source SHA") + + pull_requests = run.get("pull_requests") + if not isinstance(pull_requests, list) or len(pull_requests) != 1: + raise EvidenceError("workflow run is not bound to exactly one pull request") + pull_request = _object(pull_requests[0], "workflow run pull request") + _positive_int(pull_request.get("number"), "workflow run pull request number") + head = _object(pull_request.get("head"), "workflow run pull request head") + base = _object(pull_request.get("base"), "workflow run pull request base") + if _exact_sha(head.get("sha"), "workflow run pull request head SHA") != head_sha: + raise EvidenceError("workflow run pull request head does not match the run source SHA") + if _object(head.get("repo"), "workflow run pull request head repository").get("full_name") != expected.repository: + raise EvidenceError("workflow run pull request head repository is not trusted") + if base.get("ref") != expected.base_branch: + raise EvidenceError( + f"workflow run pull request base is {base.get('ref')!r}, expected {expected.base_branch!r}" + ) + _exact_sha(base.get("sha"), "workflow run pull request base SHA") + if _object(base.get("repo"), "workflow run pull request base repository").get("full_name") != expected.repository: + raise EvidenceError("workflow run pull request base repository is not trusted") + return run + + +def select_exact_tree_run( + runs: Iterable[Any], trees_by_sha: dict[str, str], expected: ReuseExpectation +) -> dict[str, Any] | None: + """Select the newest trusted exact-tree run, including a newer failed run.""" + candidates: list[dict[str, Any]] = [] + for raw in runs: + try: + run = trusted_pr_run(raw, expected) + except EvidenceError: + continue + if run["id"] == expected.current_run_id: + continue + if trees_by_sha.get(run["head_sha"]) != expected.source_tree: + continue + candidates.append(run) + if not candidates: + return None + return max(candidates, key=lambda run: (run["id"], run["run_attempt"])) + + +def validate_artifact( + artifact: Any, + *, + run_id: int, + expected_name: str, + archive_digest: str, + now: dt.datetime | None = None, +) -> dict[str, Any]: + artifact = _object(artifact, "artifact") + if artifact.get("name") != expected_name: + raise EvidenceError(f"artifact name is {artifact.get('name')!r}, expected {expected_name!r}") + if artifact.get("expired") is not False: + raise EvidenceError("artifact is expired") + now = now or dt.datetime.now(dt.timezone.utc) + if _timestamp(artifact.get("expires_at"), "artifact expiry") <= now: + raise EvidenceError("artifact has no remaining retention") + if _object(artifact.get("workflow_run"), "artifact workflow run").get("id") != run_id: + raise EvidenceError("artifact is not attached to the selected workflow run") + _positive_int(artifact.get("id"), "artifact ID") + if not isinstance(artifact.get("size_in_bytes"), int) or artifact["size_in_bytes"] <= 0: + raise EvidenceError("artifact is empty or has a malformed size") + metadata_digest = _digest(artifact.get("digest"), "artifact digest") + if metadata_digest != _digest(archive_digest, "downloaded artifact digest"): + raise EvidenceError("downloaded artifact digest does not match API metadata") + return artifact + + +def validate_reuse_evidence( + run: Any, + artifact: Any, + receipt: Any, + expected: ReuseExpectation, + archive_digest: str, + *, + now: dt.datetime | None = None, +) -> dict[str, Any]: + run = trusted_pr_run(run, expected) + if run.get("status") != "completed" or run.get("conclusion") != "success": + raise EvidenceError("newest exact-tree workflow run is not completed successfully") + run_id = run["id"] + run_attempt = run["run_attempt"] + expected_name = f"review-receipt-{run['head_sha']}-{run_attempt}" + artifact = validate_artifact( + artifact, + run_id=run_id, + expected_name=expected_name, + archive_digest=archive_digest, + now=now, + ) + receipt = _object(receipt, "review receipt") + _require_fields( + receipt, + { + "schema", "repository", "workflow", "event", "source_sha", "source_tree", + "app_fingerprint", "app_route", "conclusions", "run_id", "run_attempt", "app_evidence", + }, + "review receipt", + ) + identity = { + "schema": (receipt.get("schema"), expected.schema), + "repository": (receipt.get("repository"), expected.repository), + "workflow": (receipt.get("workflow"), expected.workflow), + "event": (receipt.get("event"), "pull_request"), + "source SHA": (receipt.get("source_sha"), run["head_sha"]), + "source tree": (receipt.get("source_tree"), expected.source_tree), + "app fingerprint": (receipt.get("app_fingerprint"), expected.app_fingerprint), + "app route": (receipt.get("app_route"), True), + "run ID": (receipt.get("run_id"), run_id), + "run attempt": (receipt.get("run_attempt"), run_attempt), + } + for label, (actual, wanted) in identity.items(): + if actual != wanted: + raise EvidenceError(f"review receipt {label} is {actual!r}, expected {wanted!r}") + conclusions = _object(receipt.get("conclusions"), "review receipt conclusions") + if conclusions != REQUIRED_CONCLUSIONS: + raise EvidenceError("review receipt does not contain every required successful app-job conclusion") + app_evidence = _object(receipt.get("app_evidence"), "review receipt app evidence") + if app_evidence != {"mode": "executed", "source": None}: + raise EvidenceError("review receipt is not direct executed app evidence") + return { + "run_id": run_id, + "run_attempt": run_attempt, + "artifact_id": artifact["id"], + "artifact_name": artifact["name"], + "artifact_digest": artifact["digest"], + } + + +def pages_manifest(root: pathlib.Path, repository: str, source_sha: str, source_tree: str) -> dict[str, Any]: + _exact_sha(source_sha, "Pages source SHA") + _exact_sha(source_tree, "Pages source tree") + if not root.is_dir() or root.is_symlink(): + raise EvidenceError("Pages payload root is not a regular directory") + files = [] + for path in sorted(root.rglob("*")): + relative = path.relative_to(root).as_posix() + _safe_path(relative, "Pages payload path") + if path.is_symlink(): + raise EvidenceError(f"Pages payload contains a symlink: {relative}") + if path.is_dir(): + continue + if not path.is_file(): + raise EvidenceError(f"Pages payload contains a non-file: {relative}") + info = path.stat(follow_symlinks=False) + files.append({ + "path": relative, + "mode": stat.S_IMODE(info.st_mode), + "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + }) + if not files: + raise EvidenceError("Pages payload is empty") + return { + "schema": PAGES_SCHEMA, + "repository": repository, + "source_sha": source_sha, + "source_tree": source_tree, + "files": files, + } + + +def create_pages_bundle( + root: pathlib.Path, + output: pathlib.Path, + repository: str, + source_sha: str, + source_tree: str, +) -> dict[str, Any]: + manifest = pages_manifest(root, repository, source_sha, source_tree) + manifest_bytes = (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode() + output.parent.mkdir(parents=True, exist_ok=True) + with tarfile.open(output, "w", format=tarfile.PAX_FORMAT) as archive: + records = [("manifest.json", manifest_bytes, 0o644)] + records += [ + (f"site/{record['path']}", (root / record["path"]).read_bytes(), record["mode"]) + for record in manifest["files"] + ] + for name, data, mode in records: + info = tarfile.TarInfo(name) + info.size = len(data) + info.mode = mode + info.mtime = info.uid = info.gid = 0 + info.uname = info.gname = "" + archive.addfile(info, io.BytesIO(data)) + return manifest + + +def _tar_members(bundle: pathlib.Path) -> tuple[dict[str, Any], dict[str, tuple[bytes, int]]]: + manifest: dict[str, Any] | None = None + payload: dict[str, tuple[bytes, int]] = {} + seen: set[str] = set() + try: + archive = tarfile.open(bundle, "r:") + except (OSError, tarfile.TarError) as error: + raise EvidenceError("Pages bundle is not a readable uncompressed tar archive") from error + with archive: + for member in archive: + path = _safe_path(member.name, "Pages archive path").as_posix() + if path in seen: + raise EvidenceError(f"Pages archive contains a duplicate path: {path}") + seen.add(path) + if not member.isfile() or member.issym() or member.islnk(): + raise EvidenceError(f"Pages archive contains a non-regular entry: {path}") + extracted = archive.extractfile(member) + if extracted is None: + raise EvidenceError(f"Pages archive entry cannot be read: {path}") + data = extracted.read() + if path == "manifest.json": + try: + value = json.loads(data) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise EvidenceError("Pages manifest is not valid JSON") from error + manifest = _object(value, "Pages manifest") + elif path.startswith("site/") and len(path) > len("site/"): + relative = path[len("site/"):] + _safe_path(relative, "Pages payload path") + payload[relative] = (data, member.mode) + else: + raise EvidenceError(f"Pages archive contains an unexpected path: {path}") + if manifest is None: + raise EvidenceError("Pages archive has no manifest") + return manifest, payload + + +def verify_pages_bundle( + bundle: pathlib.Path, + output: pathlib.Path, + *, + repository: str, + source_sha: str, + source_tree: str, +) -> dict[str, Any]: + manifest, payload = _tar_members(bundle) + _require_fields(manifest, {"schema", "repository", "source_sha", "source_tree", "files"}, "Pages manifest") + identity = { + "schema": (manifest.get("schema"), PAGES_SCHEMA), + "repository": (manifest.get("repository"), repository), + "source SHA": (manifest.get("source_sha"), source_sha), + "source tree": (manifest.get("source_tree"), source_tree), + } + for label, (actual, wanted) in identity.items(): + if actual != wanted: + raise EvidenceError(f"Pages manifest {label} is {actual!r}, expected {wanted!r}") + records = manifest.get("files") + if not isinstance(records, list) or not records: + raise EvidenceError("Pages manifest file list is missing or empty") + expected_paths: list[str] = [] + for record in records: + record = _object(record, "Pages manifest file") + _require_fields(record, {"path", "mode", "sha256"}, "Pages manifest file") + path = _safe_path(record.get("path"), "Pages manifest path").as_posix() + if path in expected_paths: + raise EvidenceError(f"Pages manifest repeats a path: {path}") + expected_paths.append(path) + if not isinstance(record.get("mode"), int) or record["mode"] < 0 or record["mode"] > 0o777: + raise EvidenceError(f"Pages manifest mode is malformed for {path}") + if not isinstance(record.get("sha256"), str) or not SHA256.fullmatch(record["sha256"]): + raise EvidenceError(f"Pages manifest digest is malformed for {path}") + item = payload.get(path) + if item is None: + raise EvidenceError(f"Pages payload is missing {path}") + data, mode = item + if mode != record["mode"]: + raise EvidenceError(f"Pages payload mode changed for {path}") + if hashlib.sha256(data).hexdigest() != record["sha256"]: + raise EvidenceError(f"Pages payload digest changed for {path}") + if expected_paths != sorted(expected_paths): + raise EvidenceError("Pages manifest paths are not deterministically sorted") + extra = sorted(set(payload) - set(expected_paths)) + if extra: + raise EvidenceError(f"Pages payload contains an unmanifested file: {extra[0]}") + if output.exists() and (not output.is_dir() or any(output.iterdir())): + raise EvidenceError("Pages output directory is not empty") + output.mkdir(parents=True, exist_ok=True) + for path in expected_paths: + data, mode = payload[path] + destination = output.joinpath(*pathlib.PurePosixPath(path).parts) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(data) + destination.chmod(mode) + return manifest + + +def validate_pages_source(run: Any, *, repository: str, current_main_sha: str) -> dict[str, Any]: + run = _object(run, "Pages source workflow run") + checks = { + "repository": (_object(run.get("repository"), "Pages source repository").get("full_name"), repository), + "head repository": (_object(run.get("head_repository"), "Pages source head repository").get("full_name"), repository), + "workflow": (run.get("path"), CI_WORKFLOW), + "event": (run.get("event"), "push"), + "branch": (run.get("head_branch"), "main"), + "status": (run.get("status"), "completed"), + "conclusion": (run.get("conclusion"), "success"), + } + for label, (actual, wanted) in checks.items(): + if actual != wanted: + raise EvidenceError(f"Pages source {label} is {actual!r}, expected {wanted!r}") + _positive_int(run.get("id"), "Pages source run ID") + _positive_int(run.get("run_attempt"), "Pages source run attempt") + source_sha = _exact_sha(run.get("head_sha"), "Pages source SHA") + if source_sha != _exact_sha(current_main_sha, "current main SHA"): + raise EvidenceError("Pages source is stale because main has advanced") + return run + + +def artifact_from_response(document: Any, expected_name: str) -> dict[str, Any]: + document = _object(document, "artifact response") + artifacts = document.get("artifacts") + if not isinstance(artifacts, list): + raise EvidenceError("artifact response has an unexpected shape") + matches = [artifact for artifact in artifacts if isinstance(artifact, dict) and artifact.get("name") == expected_name] + if len(matches) != 1: + raise EvidenceError(f"expected exactly one artifact named {expected_name!r}, found {len(matches)}") + return matches[0] + + +def receipt_from_artifact_zip(path: pathlib.Path) -> dict[str, Any]: + try: + archive = zipfile.ZipFile(path) + except (OSError, zipfile.BadZipFile) as error: + raise EvidenceError("review artifact is not a readable zip archive") from error + with archive: + files = [] + for info in archive.infolist(): + name = _safe_path(info.filename, "review artifact path").as_posix() + file_type = (info.external_attr >> 16) & 0o170000 + if info.is_dir() or file_type not in {0, stat.S_IFREG}: + raise EvidenceError(f"review artifact contains a non-regular entry: {name}") + files.append((name, info)) + if len(files) != 1 or files[0][0] != "review-receipt.json": + raise EvidenceError("review artifact must contain only review-receipt.json") + try: + value = json.loads(archive.read(files[0][1])) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise EvidenceError("review receipt is not valid JSON") from error + return _object(value, "review receipt") + + +def artifact_file_from_zip(path: pathlib.Path, output: pathlib.Path, expected_name: str) -> None: + try: + archive = zipfile.ZipFile(path) + except (OSError, zipfile.BadZipFile) as error: + raise EvidenceError("artifact is not a readable zip archive") from error + with archive: + files = [] + for info in archive.infolist(): + name = _safe_path(info.filename, "artifact archive path").as_posix() + file_type = (info.external_attr >> 16) & 0o170000 + if info.is_dir() or file_type not in {0, stat.S_IFREG}: + raise EvidenceError(f"artifact archive contains a non-regular entry: {name}") + files.append((name, info)) + if len(files) != 1 or files[0][0] != expected_name: + raise EvidenceError(f"artifact archive must contain only {expected_name}") + output.write_bytes(archive.read(files[0][1])) + + +def _gh_json(endpoint: str) -> Any: + result = subprocess.run( + ["gh", "api", endpoint], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + return json.loads(result.stdout) + + +def _gh_download(endpoint: str, output: pathlib.Path) -> str: + with output.open("wb") as stream: + subprocess.run(["gh", "api", endpoint], check=True, stdout=stream, stderr=subprocess.PIPE) + return f"sha256:{hashlib.sha256(output.read_bytes()).hexdigest()}" + + +def _write_outputs(path: pathlib.Path, values: dict[str, Any]) -> None: + with path.open("a", encoding="utf-8") as stream: + for key, value in values.items(): + text = "" if value is None else str(value) + if "\n" in text or "\r" in text: + raise EvidenceError(f"output {key} is multiline") + stream.write(f"{key}={text}\n") + + +def find_reuse(args: argparse.Namespace) -> int: + expected = ReuseExpectation( + repository=args.repository, + source_sha=_exact_sha(args.source_sha, "current source SHA"), + source_tree=_exact_sha(args.source_tree, "current source tree"), + app_fingerprint=_digest(f"sha256:{args.app_fingerprint}", "current app fingerprint")[7:], + current_run_id=args.current_run_id, + ) + empty = { + "reuse": "false", "source_run_id": None, "source_run_attempt": None, + "source_artifact_id": None, "source_artifact_name": None, "source_artifact_digest": None, + } + try: + response = _gh_json( + f"repos/{expected.repository}/actions/workflows/ci.yml/runs" + "?event=pull_request&status=completed&per_page=100" + ) + runs = _object(response, "workflow-run response").get("workflow_runs") + if not isinstance(runs, list): + raise EvidenceError("workflow-run response has an unexpected shape") + trees: dict[str, str] = {} + for raw in runs: + try: + run = trusted_pr_run(raw, expected) + except EvidenceError: + continue + sha = run["head_sha"] + if sha not in trees: + commit = _gh_json(f"repos/{expected.repository}/git/commits/{sha}") + trees[sha] = _exact_sha(_object(commit.get("tree"), "commit tree").get("sha"), "commit tree") + selected = select_exact_tree_run(runs, trees, expected) + if selected is None: + raise EvidenceError("no prior trusted pull-request run has the exact current Git tree") + if selected.get("status") != "completed" or selected.get("conclusion") != "success": + raise EvidenceError("newest exact-tree workflow run is not completed successfully") + name = f"review-receipt-{selected['head_sha']}-{selected['run_attempt']}" + artifacts = _gh_json(f"repos/{expected.repository}/actions/runs/{selected['id']}/artifacts?per_page=100") + artifact = artifact_from_response(artifacts, name) + with tempfile.TemporaryDirectory() as temporary: + archive = pathlib.Path(temporary) / "receipt.zip" + archive_digest = _gh_download( + f"repos/{expected.repository}/actions/artifacts/{artifact.get('id')}/zip", archive + ) + receipt = receipt_from_artifact_zip(archive) + identity = validate_reuse_evidence(selected, artifact, receipt, expected, archive_digest) + except (EvidenceError, OSError, subprocess.CalledProcessError, json.JSONDecodeError) as error: + print(f"No reusable app evidence: {error}") + _write_outputs(args.github_output, empty) + return 0 + print( + f"Reusing directly executed app evidence from run {identity['run_id']} " + f"attempt {identity['run_attempt']} artifact {identity['artifact_id']}." + ) + _write_outputs(args.github_output, { + "reuse": "true", + "source_run_id": identity["run_id"], + "source_run_attempt": identity["run_attempt"], + "source_artifact_id": identity["artifact_id"], + "source_artifact_name": identity["artifact_name"], + "source_artifact_digest": identity["artifact_digest"], + }) + return 0 + + +def create_pages(args: argparse.Namespace) -> int: + manifest = create_pages_bundle( + args.root.resolve(), args.output.resolve(), args.repository, args.source_sha, args.source_tree + ) + print(f"Created deterministic Pages bundle with {len(manifest['files'])} files.") + return 0 + + +def consume_pages(args: argparse.Namespace) -> int: + event = _object(json.loads(args.event_json.read_text()), "workflow_run event") + run = _object(event.get("workflow_run"), "workflow_run event source") + main = _gh_json(f"repos/{args.repository}/git/ref/heads/main") + current_main = _object(main.get("object"), "main ref object").get("sha") + run = validate_pages_source(run, repository=args.repository, current_main_sha=current_main) + commit = _gh_json(f"repos/{args.repository}/git/commits/{run['head_sha']}") + source_tree = _exact_sha(_object(commit.get("tree"), "Pages source commit tree").get("sha"), "Pages source tree") + name = f"pages-site-{run['head_sha']}-{run['run_attempt']}" + response = _gh_json(f"repos/{args.repository}/actions/runs/{run['id']}/artifacts?per_page=100") + artifact = artifact_from_response(response, name) + with tempfile.TemporaryDirectory() as temporary: + archive = pathlib.Path(temporary) / "pages.zip" + bundle = pathlib.Path(temporary) / "pages-payload.tar" + archive_digest = _gh_download(f"repos/{args.repository}/actions/artifacts/{artifact.get('id')}/zip", archive) + validate_artifact(artifact, run_id=run["id"], expected_name=name, archive_digest=archive_digest) + artifact_file_from_zip(archive, bundle, "pages-payload.tar") + verify_pages_bundle( + bundle, + args.output.resolve(), + repository=args.repository, + source_sha=run["head_sha"], + source_tree=source_tree, + ) + if args.github_output: + _write_outputs(args.github_output, { + "source_sha": run["head_sha"], + "source_tree": source_tree, + "source_run_id": run["id"], + "source_artifact_id": artifact["id"], + "source_artifact_digest": artifact["digest"], + }) + print(f"Verified Pages artifact {artifact['id']} from source run {run['id']} at current main.") + return 0 + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser() + commands = root.add_subparsers(dest="command", required=True) + reuse = commands.add_parser("find-reuse") + reuse.add_argument("--repository", required=True) + reuse.add_argument("--source-sha", required=True) + reuse.add_argument("--source-tree", required=True) + reuse.add_argument("--app-fingerprint", required=True) + reuse.add_argument("--current-run-id", required=True, type=int) + reuse.add_argument("--github-output", required=True, type=pathlib.Path) + reuse.set_defaults(function=find_reuse) + create = commands.add_parser("create-pages") + create.add_argument("--root", required=True, type=pathlib.Path) + create.add_argument("--output", required=True, type=pathlib.Path) + create.add_argument("--repository", required=True) + create.add_argument("--source-sha", required=True) + create.add_argument("--source-tree", required=True) + create.set_defaults(function=create_pages) + consume = commands.add_parser("consume-pages") + consume.add_argument("--event-json", required=True, type=pathlib.Path) + consume.add_argument("--repository", required=True) + consume.add_argument("--output", required=True, type=pathlib.Path) + consume.add_argument("--github-output", type=pathlib.Path) + consume.set_defaults(function=consume_pages) + return root + + +def main() -> int: + args = parser().parse_args() + try: + return args.function(args) + except (EvidenceError, OSError, subprocess.CalledProcessError, json.JSONDecodeError) as error: + print(f"workflow evidence failed: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/site/pages/de/docs.html b/site/pages/de/docs.html index 0a2d6380..08efeb5d 100644 --- a/site/pages/de/docs.html +++ b/site/pages/de/docs.html @@ -30,7 +30,7 @@

Flowlight benutzen

@@ -101,10 +101,10 @@

Die Ansichten

- - + +
請求送到哪裡內容是什麼什麼時候
更新檢查api.github.com一個查詢最新版本的標準 HTTPS 請求;GitHub 會看到你的 IP 位址和 Flowlight/<version> 這個 user agent啟動時以及每天一次,自 0.1.2 起。關閉開關在「設定」› 「一般」
更新下載github.com磁碟映像檔及其總和檢查碼檔案只有在你點「下載並開啟」時
官方規則套件更新flowlight.xinbetween.com向固定官方來源傳送的標準 HTTPS 要求。伺服器會看到你的 IP 位址、Flowlight/<version>使用者代理程式和條件式快取驗證器;不會收到任何流量、套件設定或裝置識別碼啟動後在檢查到期時執行,此後在運作正常的情況下每天最多執行一次。預設開啟;可在規則 › 套件中關閉或手動選擇立即檢查
官方規則套件更新flowlight.xinbetween.com向固定官方來源傳送的標準 HTTPS 要求。伺服器會看到你的 IP 位址、Flowlight/<version>使用者代理程式和條件式快取驗證器;不會收到任何流量、套件設定或裝置識別碼啟動後在檢查到期時執行,此後在運作正常的情況下每天最多執行一次。預設開啟;可在「政策 › 受管理套件」中關閉或手動選擇立即檢查
反向 DNS你設定的 DNS 解析器在沒有看到主機名稱時,查詢某個 IP 位址登記的名稱需要時進行,在取樣器擷取模式下,也就是網路延伸功能開始運作之前的預設模式。沒有關閉開關:少了它,取樣器就沒辦法為大多數連線命名。網路延伸功能會從流量本身為它們命名,因此不做這件事
網路擁有者查詢Team Cymru,透過 DNS沒有已知主機名稱的公開 IP 位址。私有和保留位址絕不會離開你的 Mac預設開啟。關閉開關在「擷取」› 「辨識網路擁有者」
用託管模型提問你選擇的供應商:Anthropic、OpenAI、Google Gemini 或相容的端點你的問題,以及模型所選用的 Flowlight 唯讀查詢回傳的結果。流量資料庫本身不會被上傳只有在你選擇託管供應商並送出問題時。「提問」畫面會顯示送出的請求
.*?(.*?)Warnungen⌘5Alles, was ungewöhnlich aussah, mit App, Ziel und dem Wert, der die Regel ausgelöst hat.
Fragen⌘6Fragen zu deinem eigenen Datenverkehr in normaler Sprache, beantwortet aus der aufgezeichneten Historie von einem lokalen oder einem gehosteten Modell.
Inspizieren⌘7 · Developer IDOptional: entschlüsselte Anfragen der Agenten, die du über Flowlight leitest, und die Tool-Aufrufe dahinter. Die Suche reicht in Anfrage- und Antwort-Bodys hinein.
Regeln⌘8 · Developer ID
⌘7 · App Store
Jede Erlaubnisliste und jede Verweigerung, die du gesetzt hast, was jede davon abdeckt und was sie tatsächlich gestoppt hat.
Quellen⌘9 · Developer ID
⌘8 · App Store
Collector-Zustand, Gerätekanäle und Benennungsabdeckung in drei Tabs.
Richtlinien⌘8 · Developer ID
⌘7 · App Store
Jede konfigurierte Richtlinie, ob sie tatsächlich wirksam ist, und ein einheitlicher schreibgeschützter Aktivitätsverlauf.
Quellen⌘9 · Developer ID
⌘8 · App Store
Zustand und Wiederherstellung des Collectors, optionale Gerätekanäle und Abdeckung auf einer einzigen scrollbaren Betriebsseite.
-

Die Kurzbefehle folgen der sichtbaren Reihenfolge der Seitenleiste. Developer-ID-Versionen enthalten Inspizieren auf ⌘7; App-Store-Versionen lassen es weg, daher rücken Regeln und Quellen auf ⌘7 und ⌘8.

+

Die Kurzbefehle folgen der sichtbaren Reihenfolge der Seitenleiste. Developer-ID-Versionen enthalten Inspizieren auf ⌘7; App-Store-Versionen lassen es weg, daher rücken Richtlinien und Quellen auf ⌘7 und ⌘8.

Der Bildschirm Live: eine Liste von Anwendungen mit ihren aktuellen Sende- und Empfangsraten und ihrem meistgenutzten Ziel.
Live, der Bildschirm, mit dem Flowlight startet.
@@ -147,7 +147,7 @@

Tools & MCP-Server

eine nur konfigurierte Domain nie erreicht wurde und eine nur beobachtete nirgends aufgeschrieben stand, wo du sie hättest lesen können.

Jede Domain bietet zwei Verweigerungen an: für diesen Agenten, was den Agenten und die von ihm gestarteten Tools abdeckt, oder überall, was jede App auf dem Mac abdeckt. Beide schreiben eine ganz gewöhnliche Regel, sodass der - Verstoß-Verlauf sie meldet und der Regeleditor sie erklärt – und weil es gewöhnliche Regeln sind, sagt dir die Simulation im Editor, was + schreibgeschützte Aktivität sie meldet und der Regeleditor sie erklärt – und weil es gewöhnliche Regeln sind, sagt dir die Simulation im Editor, was eine davon verweigert hätte, bevor du sie einschaltest.

Eine Art von Domain hat keine Knöpfe. Ein Connector, den der Modellanbieter von sich aus erreicht – im Body der Anfrage deklariert und von der Seite des Anbieters aus kontaktiert –, berührt diesen Mac überhaupt nicht, also kann keine Regel hier ihn erreichen. Die Zeile sagt @@ -338,16 +338,16 @@

Mock-Antworten

verbunden: Ein Mock ändert die Antwort, nicht die Frage, ob die Verbindung zustande kommt. „Die API liefert 500“ funktioniert also, „die API ist überhaupt nicht erreichbar“ nicht.

-

Regeln

-

Eine Erlaubnisliste ist die Vorstellung eines Agenten davon, wohin er gehen darf. Eine Regel ist die allgemeine Fassung: Sie nennt ein +

Richtlinien

+

Unter Richtlinien sind alle Kontrollen, die Flowlight anwenden kann, in einem gemeinsamen Verzeichnis zusammengefasst: gewöhnliche Erlaubnis- und Sperrregeln, Agenten-Erlaubnislisten, verwaltete Pakete und – wo die HTTPS-Inspektion verfügbar ist – Schutzregeln, Mock-Antworten, Anfrageumschreibungen und Antworttransformationen. Jede Zeile unterscheidet den konfigurierten Zustand von der tatsächlichen Wirkung: wirksam, nur meldend, beobachtend, wartend, geplant, pausiert, verbraucht, in Anwendung, Prüfung erforderlich, nicht verfügbar, inkompatibel oder fehlgeschlagen. Wenn du eine Zeile auswählst, öffnet sich der zugehörige Editor dieser Richtlinie; die gemeinsame Ansicht ersetzt oder vermischt nicht deren unterschiedliche Einwilligungs- und Speichermodelle.

+

Eine Erlaubnisliste ist die Vorstellung eines Agenten davon, wohin er gehen darf. Eine gewöhnliche Regel ist die allgemeine Fassung: Sie nennt ein Subjekt – eine App, einen Agenten, ein Ziel, eine URL oder ein Paar aus zwei davon – und sagt blockieren oder erlauben. Rechtsklicke eine Zeile in Live, Verlauf, KI-Agenten oder Inspizieren, um eine Regel über das zu schreiben, was du gerade ansiehst; - jede davon landet im selben Editor auf dem Bildschirm Regeln, denn eine Regel, die im Verborgenen greift, - wäre eine Regel, die du nie wiederfindest.

+ jede bleibt unter Richtlinien auffindbar und öffnet sich in ihrem maßgeblichen Editor.

Vorlagen. Zweiundzwanzig, in fünf Gruppen – einen Agenten von Paketregistern oder anderen Modellanbietern fernhalten, die üblichen Wege schließen, auf denen Daten abfließen (Paste-Dienste, Webhook-Fänger, anonyme Datei-Ablagen, Tunnel, DNS over HTTPS), Produktanalyse und Crash-Berichte, Regeln für Arbeitszeiten und das einmalige „blockiere das eine Stunde lang, bis ich - herausgefunden habe, was es ist“. Durchsuche sie über die Symbolleiste von Regeln, oder rechtsklicke irgendwo in Live, Verlauf, KI-Agenten oder Inspizieren eine Zeile und + herausgefunden habe, was es ist“. Durchsuche sie über die Symbolleiste von Richtlinien, oder rechtsklicke irgendwo in Live, Verlauf, KI-Agenten oder Inspizieren eine Zeile und wende eine auf das an, was du ansiehst.

Jede Vorlage sagt, was sie tun wird, bevor sie es tut. Alles, was mehr als eine Regel schreibt, öffnet eine Karte mit jedem Host und jedem Zeitplan; alles Dauerhafte öffnet den vorausgefüllten Editor; und nur Regeln, die sich selbst aufheben – nach einer @@ -368,8 +368,7 @@

Regeln

Adresse und Port, nichts über Pfade, und das nur im Erweiterungsmodus. Die HTTPS-Inspektion verweigert eine einzelne Anfrage, kann also eine URL treffen und mit einem Status antworten, den der Agent lesen kann, statt mit einem Socket, der ohne Erklärung starb. Der nettop-Sampler kann überhaupt nichts verweigern. Eine Regel, die die aktuelle Einrichtung nicht umsetzen kann, sagt das in der Liste, statt dort durchgesetzt auszusehen.

-

Der zweite Tab ist der Verstoß-Verlauf: jede Verbindung, über die eine Regel entschieden hat, mit der App, dem Ziel, welche - Regel entschied und ob das eine Verweigerung war oder eine Ausnahme, die sie durchließ.

+

Die zweite Ansicht ist die schreibgeschützte Aktivität: gewöhnliche Regelentscheidungen, Verstöße gegen Erlaubnislisten, Ereignisse verwalteter Pakete und eigenständige Wirkungen von Inspektionsrichtlinien in einer gemeinsamen Chronologie. Sie bereitet die bestehenden maßgeblichen Einträge auf, statt den Verlauf in ein einheitliches allgemeines Ereignismodell umzuschreiben; App-Store-Versionen lassen von der Inspektion abhängige Richtlinienarten und deren aufbewahrte Aktivität weg.

Was sie bewirkt hätte. Eine Regel, die blockiert, ist leicht zu schreiben und beängstigend einzuschalten, denn das Erste, was man normalerweise über eine solche Regel erfährt, ist, was sie kaputt gemacht hat – und man erfährt es später, @@ -391,7 +390,7 @@

Regeln

getestet wurde, ist gegen zwei Stunden Aufzeichnung getestet.

Verwaltete Werbe- und Tracker-Pakete

-

Regeln › Pakete enthält drei offizielle Hostnamenlisten: Werbung, Websiteübergreifendes Tracking und Datenschutz und Telemetrie. Sie sind von Anfang an sichtbar und standardmäßig alle deaktiviert. Zum Blockieren ist die Netzwerkerweiterung erforderlich; der nettop-Sampler kann nur beobachten.

+

Richtlinien › Verwaltete Pakete enthält drei offizielle Hostnamenlisten: Werbung, Websiteübergreifendes Tracking und Datenschutz und Telemetrie. Sie sind von Anfang an sichtbar und standardmäßig alle deaktiviert. Zum Blockieren ist die Netzwerkerweiterung erforderlich; der nettop-Sampler kann nur beobachten.

Ausschließlich offizielle, signierte Updates. Flowlight prüft eine einzige festgelegte HTTPS-Quelle auf neuere, vom Herausgeber signierte Kataloge. Automatische Prüfungen sind standardmäßig aktiviert und können hier deaktiviert werden. Eine Signatur authentifiziert den Inhalt, aktiviert ihn aber nicht: Ein neues Paket bleibt deaktiviert, und bei geänderten Zielen wird ein aktiviertes Paket nicht mehr durchgesetzt, bis du die genaue neue Version geprüft und auf Sichern geklickt hast. Akzeptierte Inhalte können während eines Ausfalls als sichtbar veralteter letzter bekannter funktionsfähiger Stand erhalten bleiben. Dies sind Netzwerksperren auf Hostnamenebene: Sie können keine Lücken im DOM oder Layout, Browser-Overlays, skriptgesteuerten Pop-ups oder Fenster nativer Apps entfernen.

Wenn die HTTPS-Inspektion eine App durch Flowlights Proxy leitet, entscheidet er anhand der ursprünglichen App-Identität über gewöhnliche Zielregeln und aktivierte verwaltete Pakete, bevor eine Verbindung zum Ursprungsserver geöffnet wird. Bei angepinnten oder nie entschlüsselten CONNECT-Tunneln funktioniert das anhand des Hostnamens; Agenten-Erlaubnislisten bleiben auf den direkten Weg über die Netzwerkerweiterung beschränkt. Ist der Eigentümer oder Hostname unbekannt, wird die Verbindung im Zweifel zugelassen.

@@ -440,7 +439,8 @@

Schutzregeln für Agenten: ein Tool, einen Server oder eine

Das ist keine Sandbox. Ein verweigertes Tool wird nicht mehr angeboten und nicht mehr beantwortet. Ein Agent, der noch eine Shell hat, kann von Hand tun, was das Tool getan hätte. Flowlight zeichnet das auf; es tut nicht so, als würde es das verhindern.

-

Quellen: Collector und Engines

+

Quellen: Collector, Geräte und Abdeckung

+

Quellen ist eine einzige scrollbare Betriebsseite: zuerst Collector, dann Geräte und Abdeckung als klar bezeichnete Abschnitte. Statushinweise, Antworten unter Fragen, wiederhergestellte Fenster und Links aus Screenshots lassen die Seite zum passenden Abschnitt scrollen und heben ihn kurz hervor, ohne die anderen auszublenden.

Flowlight zeichnet Datenverkehr auf eine von zwei Weisen auf, wählbar unter Quellen › Collector. Sie unterscheiden sich darin, was sie verpassen können, und das lohnt sich zu verstehen, bevor du dich auf eine verlässt.

@@ -637,7 +637,7 @@

Fragen & Antworten

Mit geschlossenem Fenster tut Flowlight fast nichts: Es hält die Raten in der Menüleiste und schreibt eine Zeile pro Sekunde. Die Netzwerkerweiterung kostet weniger als der Sampler als Rückfallebene, der jede Sekunde einen kurzlebigen nettop-Prozess startet.
Kann es Verbindungen blockieren?
-
Ja, mit der Netzwerkerweiterung. Die Erlaubnisliste eines Agenten kann verweigern und nicht nur warnen, eine Regel kann eine App, +
Ja, mit der Netzwerkerweiterung. Die Erlaubnisliste eines Agenten kann verweigern und nicht nur warnen, eine Regel kann eine App, ein Ziel oder eine URL so lange blockieren, wie du sagst, und eine Schutzregel kann einem Agenten ein Tool wegnehmen, bevor sein Modell es angeboten bekommt. Nichts blockiert, bis du es verlangst, und jede Verweigerung führt ihren Ausweg mit.
Brauche ich die Netzwerkerweiterung?
diff --git a/site/pages/de/index.html b/site/pages/de/index.html index ee2744f4..8393aae2 100644 --- a/site/pages/de/index.html +++ b/site/pages/de/index.html @@ -110,11 +110,11 @@

Verlauf

Wo ist alles hingegangen? Sekunde bis Jahr, gruppiert nach Ap

Warnungen

Was sah falsch aus? Nachvollziehbare Warnungen pro App, mit Ziel und Schwellenwert.

⌘5
-

Regeln

Was habe ich verweigert – und passiert es? Jede Regel, was sie blockiert hat, und ein Verlauf jeder Entscheidung.

⌘8 · Developer ID / ⌘7 · App Store
+

Richtlinien

Welche Kontrollen gibt es, und sind sie wirksam? Jede Richtlinie, ihr tatsächlicher Zustand und ein einheitlicher Aktivitätsverlauf.

⌘8 · Developer ID / ⌘7 · App Store

Fragen

Fragen in normaler Sprache, beantwortet aus dem Verlauf – mit jeder ausgeführten Abfrage darunter.

⌘6
-

Quellen

Kommen Daten an, wie viel ist benannt und welche Gerätekanäle sind aktiv? Collector, Geräte und Abdeckung an einem Ort.

⌘9 · Developer ID / ⌘8 · App Store
+

Quellen

Kommen Daten an, welche Gerätekanäle sind aktiv, und was kann Flowlight erfassen? Eine Seite vom Collector bis zur Abdeckung.

⌘9 · Developer ID / ⌘8 · App Store

Inspizieren

Optional. Was hat der Agent wirklich gesendet, und welcher Tool-Aufruf hat es ausgelöst? Entschlüsselte Anfragen, Tool-Aufrufe, Ergebnisse.

⌘7 · Developer ID
diff --git a/site/pages/de/privacy.html b/site/pages/de/privacy.html index 9d1b5e6d..f8594a18 100644 --- a/site/pages/de/privacy.html +++ b/site/pages/de/privacy.html @@ -56,7 +56,7 @@

Netzwerkanfragen, die die App stellt

- + diff --git a/site/pages/docs.html b/site/pages/docs.html index 76ef74f5..5cae73ef 100644 --- a/site/pages/docs.html +++ b/site/pages/docs.html @@ -31,7 +31,7 @@

Using Flowlight

@@ -102,10 +102,10 @@

The views

- - + +
AnfrageGesendet anWas sie enthältWann
Update-Prüfungapi.github.comEine normale HTTPS-Anfrage nach der neuesten Version; GitHub sieht deine IP-Adresse und den User-Agent Flowlight/<version>Beim Start und täglich, ab 0.1.2. Abschalten in Einstellungen › Allgemein
Update-Downloadgithub.comDas Image und seine PrüfsummendateiNur wenn du auf „Herunterladen & öffnen“ klickst
Offizielle Regelpaket-Aktualisierungflowlight.xinbetween.comEine standardmäßige HTTPS-Anfrage an den festgelegten offiziellen Feed. Der Server sieht deine IP-Adresse, den User-Agent Flowlight/<version> und bedingte Cache-Validatoren; er erhält weder Datenverkehr noch Paketeinstellungen oder eine GerätekennungNach dem Start, sobald eine Prüfung fällig ist, und danach bei störungsfreiem Betrieb höchstens einmal täglich. Standardmäßig aktiviert; Ausschalter und manuelle Option Jetzt suchen unter Regeln › Pakete
Offizielle Regelpaket-Aktualisierungflowlight.xinbetween.comEine standardmäßige HTTPS-Anfrage an den festgelegten offiziellen Feed. Der Server sieht deine IP-Adresse, den User-Agent Flowlight/<version> und bedingte Cache-Validatoren; er erhält weder Datenverkehr noch Paketeinstellungen oder eine GerätekennungNach dem Start, sobald eine Prüfung fällig ist, und danach bei störungsfreiem Betrieb höchstens einmal täglich. Standardmäßig aktiviert; Ausschalter und manuelle Option Jetzt suchen unter Richtlinien › Verwaltete Pakete
Reverse-DNSDer von dir eingerichtete DNS-ResolverEine Abfrage des registrierten Namens einer IP-Adresse, wenn kein Hostname gesehen wurdeNach Bedarf, im Erfassungsmodus des Samplers, der Standard ist, bis die Netzwerkerweiterung läuft. Kein Schalter zum Abschalten: ohne sie kann der Sampler die meisten Verbindungen nicht benennen. Die Netzwerkerweiterung benennt sie aus dem Datenverkehr selbst und macht diese Abfrage nicht
Abfrage des NetzinhabersTeam Cymru, über DNSÖffentliche IP-Adressen ohne bekannten Hostnamen. Private und reservierte Adressen verlassen deinen Mac nieStandardmäßig ein. Abschalten in Erfassung › Netzinhaber bestimmen
Fragen mit einem gehosteten ModellDer Anbieter, den du wählst: Anthropic, OpenAI, Google Gemini oder ein kompatibler EndpunktDeine Frage und die Ergebnisse der nur lesenden Flowlight-Abfragen, die das Modell ausgewählt hat. Die Verkehrsdatenbank selbst wird nicht hochgeladenNur wenn du einen gehosteten Anbieter wählst und eine Frage abschickst. Der Bildschirm Fragen zeigt die gesendeten Anfragen
Alerts⌘5Everything that looked unusual, with the app, the destination and the number that tripped the rule.
Ask⌘6Questions about your own traffic in plain language, answered from the recorded history by a local or a hosted model.
Inspect⌘7 · Developer IDOptional: decrypted requests from the agents you route through Flowlight, and the tool calls behind them. Search reaches into request and response bodies.
Rules⌘8 · Developer ID
⌘7 · App Store
Every allowlist and refusal you have set, what each one covers, and what it has actually stopped.
Sources⌘9 · Developer ID
⌘8 · App Store
Collector health and recovery, hostname coverage, and opt-in camera, Bluetooth, USB and storage activity in three tabs.
Policies⌘8 · Developer ID
⌘7 · App Store
Every configured policy, whether it is actually effective, and a unified read-only activity trail.
Sources⌘9 · Developer ID
⌘8 · App Store
Collector health and recovery, opt-in device channels, and coverage as one scrollable operational page.
-

The shortcuts follow the visible sidebar order. Developer ID builds include Inspect at ⌘7; App Store builds omit Inspect, so Rules and Sources move to ⌘7 and ⌘8.

+

The shortcuts follow the visible sidebar order. Developer ID builds include Inspect at ⌘7; App Store builds omit Inspect, so Policies and Sources move to ⌘7 and ⌘8.

The Live screen: a list of applications with their current send and receive rates and busiest destination.
Live, the screen Flowlight opens on.
@@ -148,7 +148,7 @@

Tools & MCP servers

in a configuration file and actually contacted says more than either alone, while a domain only ever configured has never been reached, and one only ever observed was never written down anywhere you could have read it.

Each domain offers two refusals: for this agent, which covers the agent and the tools it starts, or - everywhere, which covers every app on the Mac. Both write an ordinary rule, so the violations feed reports + everywhere, which covers every app on the Mac. Both write an ordinary rule, so Activity reports them and the rule editor explains them — and because they are ordinary rules, the simulation in the editor will tell you what one would have refused before you switch it on.

One kind of domain has no buttons. A connector the model provider reaches on its own — declared in the request body and @@ -347,16 +347,20 @@

Mock responses

connected to: a mock changes the answer, not whether the connection can be made, so "the API returns 500" works while "the API is entirely unreachable" does not.

-

Rules

-

An allowlist is one agent's idea of where it may go. A rule is the general version: it names a +

Policies

+

Policies is one inventory for every control Flowlight can apply: ordinary allow/block rules, agent + allowlists, managed packs, and—where HTTPS inspection is available—guardrails, mocks, request rewrites, and response + transforms. Each row distinguishes configured state from actual effect: enforcing, reporting, watching, waiting, + scheduled, paused, spent, applying, review required, unavailable, incompatible, or failed. Selecting a row opens the + contextual editor that owns that policy; Policies does not replace or blur those separate consent and persistence models.

+

An allowlist is one agent's idea of where it may go. An ordinary rule is the general version: it names a subject — an app, an agent, a destination, a URL, or a pairing of two of those — and says block or allow. Right-click any row in Live, History, AI Agents or Inspect to write one about what you're looking at; - every one of them lands in the same editor on the Rules screen, because a rule that took effect out of sight - would be a rule you could never find again.

+ every one remains findable in Policies and opens in its authoritative editor.

Templates. Twenty-two of them, in five groups — stopping an agent reaching package registries or other model providers, closing the common ways data leaves (paste sites, webhook catchers, anonymous file drops, tunnels, DNS over HTTPS), product analytics and crash reporting, working-hours policies, and one-off "block this for an hour while I work out - what it is". Browse them from the Rules toolbar, or right-click a row anywhere in Live, History, AI Agents or Inspect and + what it is". Browse them from the Policies toolbar, or right-click a row anywhere in Live, History, AI Agents or Inspect and apply one to what you're looking at.

Each template says what it will do before it does it. Anything that writes more than one rule opens a card listing every host and schedule; anything permanent opens the editor pre-filled; and only rules that undo themselves — an @@ -377,8 +381,9 @@

Rules

address and the port, nothing about paths, and only in extension mode. HTTPS inspection refuses one request, so it can match a URL and answer with a status the agent can read instead of a socket that died without saying why. The nettop sampler cannot refuse anything at all. A rule the current setup can't carry out says so in the list rather than sitting there looking enforced.

-

The second tab is the violations feed: every connection a rule decided, with the app, where it was headed, which - rule decided it and whether that was a refusal or an exception letting it through.

+

The second view is read-only Activity: ordinary rule decisions, allowlist violations, managed-pack events, + and distinct inspection-policy effects in one chronology. It adapts the existing authoritative records rather than rewriting + history into one generic event model; Store builds omit inspection-dependent policy kinds and their retained activity.

What it would have done. A blocking rule is easy to write and frightening to switch on, because the first thing you normally learn about one is what it broke — and you learn it later, from something failing somewhere else. The rule @@ -398,7 +403,7 @@

Rules

history has been tested against two hours of history.

Managed ad and tracker packs

-

Rules › Packs contains three official lists built into Flowlight: Advertising covers known +

Policies › Managed packs contains three official lists built into Flowlight: Advertising covers known advertising delivery and auction hosts; Cross-site tracking covers common analytics, session replay and cross-site measurement hosts; and Privacy and telemetry covers crash reporting, install attribution, and audited Apple advertising and analytics hosts. The packs are visible from the start and all are off by default.

@@ -483,8 +488,10 @@

Agent guardrails: block a tool, a server or a resource

This is not a sandbox. A refused tool stops being offered and stops being answered. An agent that still has a shell can do by hand what the tool would have done. Flowlight will record that; it does not pretend to prevent it.

-

Sources: collector, coverage and devices

-

Sources keeps operational setup in one place. Its health summary shows whether traffic is arriving, how much is named, and which optional device channels are active; any unfinished collector step links directly to Sources › Collector.

+

Sources: collector, devices and coverage

+

Sources is one scrollable operational page: Collector first, then Devices and Coverage as clearly headed + sections. Status notices, Ask answers, restored windows, and screenshot links scroll to and briefly focus the relevant section + without hiding the others.

Flowlight records traffic one of two ways, chosen under Sources › Collector. They differ in what they can miss, which is worth understanding before you rely on either.

@@ -687,7 +694,7 @@

Questions & answers

With the window closed Flowlight does almost nothing: it keeps the menu bar rates and writes one row a second. The Network Extension costs less than the fallback sampler, which starts a short-lived nettop process every second.
Can it block connections?
-
Yes, with the Network Extension. An agent's allowlist can refuse as well as warn, a rule can block an app, +
Yes, with the Network Extension. An agent's allowlist can refuse as well as warn, a rule can block an app, a destination or a URL for as long as you say, and a guardrail can take a tool away from an agent before its model is offered it. Nothing blocks until you ask it to, and every refusal carries its way out.
Do I need the Network Extension?
diff --git a/site/pages/es/docs.html b/site/pages/es/docs.html index 952f8f79..19b08858 100644 --- a/site/pages/es/docs.html +++ b/site/pages/es/docs.html @@ -30,7 +30,7 @@

Usar Flowlight

@@ -101,10 +101,10 @@

Las vistas

- - + +
Alertas⌘5Todo lo que ha resultado inusual, con la app, el destino y la cifra que activó la regla.
Preguntar⌘6Preguntas sobre tu propio tráfico en lenguaje corriente, respondidas a partir del historial registrado por un modelo local o alojado.
Inspeccionar⌘7 · Developer IDOpcional: las peticiones descifradas de los agentes que encamines por Flowlight, y las llamadas de herramientas que hay detrás. La búsqueda llega a los cuerpos de las peticiones y las respuestas.
Reglas⌘8 · Developer ID
⌘7 · App Store
Cada lista de permitidos y cada rechazo que hayas puesto, qué abarca cada uno y qué ha detenido realmente.
Fuentes⌘9 · Developer ID
⌘8 · App Store
Estado del colector, canales de dispositivos y cobertura de nombres en tres pestañas.
Políticas⌘8 · Developer ID
⌘7 · App Store
Cada política configurada, si está surtiendo efecto de verdad y un historial de actividad unificado de solo lectura.
Fuentes⌘9 · Developer ID
⌘8 · App Store
Estado y recuperación del colector, canales de dispositivos opcionales y cobertura en una sola página operativa desplazable.
-

Los atajos siguen el orden visible de la barra lateral. Las versiones Developer ID incluyen Inspeccionar en ⌘7; las de App Store lo omiten, así que Reglas y Fuentes pasan a ⌘7 y ⌘8.

+

Los atajos siguen el orden visible de la barra lateral. Las versiones Developer ID incluyen Inspeccionar en ⌘7; las de App Store lo omiten, así que Políticas y Fuentes pasan a ⌘7 y ⌘8.

La pantalla En vivo: una lista de aplicaciones con sus tasas actuales de envío y recepción y su destino más activo.
En vivo, la pantalla con la que se abre Flowlight.
@@ -146,7 +146,7 @@

Herramientas y servidores MCP

separado, mientras que un dominio solo configurado nunca se ha alcanzado, y uno solo observado no estaba escrito en ningún sitio donde pudieras haberlo leído.

Cada dominio ofrece dos rechazos: para este agente, que cubre al agente y a las herramientas que arranca, o en - todas partes, que cubre todas las apps del Mac. Ambos escriben una regla corriente, así que el flujo de incumplimientos los informa + todas partes, que cubre todas las apps del Mac. Ambos escriben una regla corriente, así que Actividad los registra y el editor de reglas los explica — y, por ser reglas corrientes, la simulación del editor te dirá qué habría rechazado uno de ellos antes de que lo actives.

Hay un tipo de dominio que no trae botones. Un conector al que el proveedor del modelo llega por su cuenta —declarado en el cuerpo de la @@ -335,16 +335,16 @@

Respuestas simuladas

una simulación cambia la respuesta, no si la conexión puede hacerse, así que «la API devuelve 500» funciona y «la API es del todo inalcanzable» no.

-

Reglas

-

Una lista de permitidos es la idea que tiene un agente de adónde puede ir. Una regla es la versión general: nombra un +

Políticas

+

Políticas reúne en un solo inventario todos los controles que Flowlight puede aplicar: reglas normales de permiso o bloqueo, listas de permitidos de agentes, paquetes gestionados y, donde esté disponible la inspección HTTPS, salvaguardas, simulaciones, reescrituras de peticiones y transformaciones de respuestas. Cada fila distingue entre el estado configurado y el efecto real: en vigor, solo informando, vigilando, en espera, programada, en pausa, agotada, en proceso de aplicación, pendiente de revisión, no disponible, incompatible o fallida. Al seleccionar una fila se abre el editor contextual al que pertenece esa política; Políticas no sustituye ni confunde sus distintos modelos de consentimiento y persistencia.

+

Una lista de permitidos es la idea que tiene un agente de adónde puede ir. Una regla normal es la versión general: nombra un sujeto —una app, un agente, un destino, una URL o una pareja de dos de ellos— y dice bloquear o permitir. Haz clic derecho en cualquier fila de En vivo, Historial, Agentes de IA o Inspeccionar para escribir una sobre lo que estás mirando; - todas acaban en el mismo editor de la pantalla Reglas, porque una regla que surtiera efecto fuera de la vista - sería una regla que ya nunca podrías encontrar.

+ todas siguen localizables en Políticas y se abren en su editor de referencia.

Plantillas. Veintidós, en cinco grupos: impedir que un agente llegue a registros de paquetes o a otros proveedores de modelos, cerrar las vías habituales por las que salen los datos (sitios de pegado, receptores de webhooks, servicios anónimos de subida de archivos, túneles, DNS over HTTPS), analítica de producto e informes de errores, políticas de horario laboral, y el «bloquea esto una hora mientras averiguo - qué es» puntual. Explóralas desde la barra de herramientas de Reglas, o haz clic derecho en una fila de En vivo, Historial, Agentes de IA o Inspeccionar y + qué es» puntual. Explóralas desde la barra de herramientas de Políticas, o haz clic derecho en una fila de En vivo, Historial, Agentes de IA o Inspeccionar y aplica una a lo que estés mirando.

Cada plantilla dice qué va a hacer antes de hacerlo. Todo lo que escriba más de una regla abre una tarjeta con la lista de cada host y cada horario; todo lo permanente abre el editor ya rellenado; y solo las reglas que se deshacen solas —una @@ -365,8 +365,7 @@

Reglas

dirección y el puerto, nada de rutas, y solo en el modo de extensión. La inspección HTTPS rechaza una petición concreta, así que puede casar con una URL y responder con un estado que el agente pueda leer en lugar de un socket que murió sin explicar por qué. El muestreador nettop no puede rechazar nada en absoluto. Una regla que la configuración actual no puede aplicar lo dice en la lista en vez de quedarse ahí aparentando estar en vigor.

-

La segunda pestaña es el flujo de incumplimientos: cada conexión que decidió una regla, con la app, adónde iba, qué - regla lo decidió y si fue un rechazo o una excepción que la dejó pasar.

+

La segunda vista es Actividad, de solo lectura: reúne en una cronología las decisiones de reglas normales, los incumplimientos de listas de permitidos, los eventos de paquetes gestionados y los efectos diferenciados de las políticas de inspección. Adapta los registros de referencia existentes en vez de reescribir el historial en un modelo de eventos genérico; las versiones de la App Store omiten los tipos de políticas que dependen de la inspección y su actividad conservada.

Qué habría hecho. Una regla de bloqueo es fácil de escribir y da miedo activarla, porque lo primero que normalmente averiguas sobre una regla así es qué rompió —y lo averiguas más tarde, porque algo falla en otro sitio—. El editor de reglas @@ -385,7 +384,7 @@

Reglas

probado con dos horas de historial.

Paquetes gestionados de publicidad y rastreadores

-

Reglas › Paquetes contiene tres listas oficiales de nombres de host: Publicidad, Seguimiento entre sitios y Privacidad y telemetría. Son visibles desde el principio y todas están desactivadas de forma predeterminada. El bloqueo requiere la Extensión de red; el muestreador nettop solo puede observar.

+

Políticas › Paquetes gestionados contiene tres listas oficiales de nombres de host: Publicidad, Seguimiento entre sitios y Privacidad y telemetría. Son visibles desde el principio y todas están desactivadas de forma predeterminada. El bloqueo requiere la Extensión de red; el muestreador nettop solo puede observar.

Solo actualizaciones oficiales y firmadas. Flowlight consulta una única fuente HTTPS fija para buscar catálogos más recientes firmados por el editor. Las comprobaciones automáticas están activadas de forma predeterminada y pueden desactivarse aquí. Una firma autentica el contenido, pero no lo activa: un paquete nuevo permanece desactivado y, si cambian los destinos, un paquete activado deja de aplicarse hasta que revises la nueva versión exacta y pulses Guardar. Durante una interrupción, el contenido aceptado puede conservarse como la última versión válida conocida, marcada visiblemente como obsoleta. Estos son bloqueos de red a nivel de nombre de host: no pueden eliminar huecos del DOM o del diseño, superposiciones del navegador, ventanas emergentes generadas por scripts ni ventanas de aplicaciones nativas.

Cuando la inspección HTTPS encamina una app por el proxy de Flowlight, este decide las reglas de destino normales y los paquetes gestionados activados usando la identidad original de la app antes de abrir ninguna conexión con el servidor de origen. En los túneles CONNECT con certificado fijado o que nunca se descifran, esto funciona por nombre de host; las listas de permitidos de agentes siguen limitadas al camino directo por la Extensión de red. Si no se conoce el propietario o el nombre de host, la conexión se permite.

@@ -434,7 +433,8 @@

Salvaguardas de agente: bloquear una herramienta, un servido

Esto no es un sandbox. Una herramienta rechazada deja de ofrecerse y deja de responderse. Un agente que siga teniendo un shell puede hacer a mano lo que habría hecho la herramienta. Flowlight lo registrará; no pretende impedirlo.

-

Fuentes: colector y motores

+

Fuentes: colector, dispositivos y cobertura

+

Fuentes es una sola página operativa desplazable: primero Colector y después Dispositivos y Cobertura como secciones claramente encabezadas. Los avisos de estado, las respuestas de Preguntar, las ventanas restauradas y los enlaces de capturas de pantalla hacen que la página se desplace hasta la sección correspondiente y la resaltan brevemente sin ocultar las demás.

Flowlight registra el tráfico de una de dos maneras, que se eligen en Fuentes › Colector. Se diferencian en lo que pueden perderse, y conviene entenderlo antes de confiar en cualquiera de las dos.

@@ -630,7 +630,7 @@

Preguntas y respuestas

Con la ventana cerrada, Flowlight casi no hace nada: mantiene las tasas de la barra de menús y escribe una fila por segundo. La extensión de red cuesta menos que el muestreador de reserva, que arranca un proceso nettop efímero cada segundo.
¿Puede bloquear conexiones?
-
Sí, con la extensión de red. La lista de permitidos de un agente puede rechazar además de avisar, una regla puede bloquear una app, +
Sí, con la extensión de red. La lista de permitidos de un agente puede rechazar además de avisar, una regla puede bloquear una app, un destino o una URL el tiempo que digas, y una salvaguarda puede quitarle una herramienta a un agente antes de que se le ofrezca a su modelo. Nada se bloquea hasta que tú lo pides, y cada rechazo lleva consigo su salida.
¿Necesito la extensión de red?
diff --git a/site/pages/es/index.html b/site/pages/es/index.html index ea7a73b7..2e78edea 100644 --- a/site/pages/es/index.html +++ b/site/pages/es/index.html @@ -110,11 +110,11 @@

Historial

¿Adónde ha ido todo? Del segundo al año, agrupado por ap

Alertas

¿Qué ha pintado mal? Alertas por app, explicables, con el destino y el umbral.

⌘5
-

Reglas

¿Qué he rechazado y se está cumpliendo? Cada regla, lo que ha bloqueado y un flujo con todas las decisiones.

⌘8 · Developer ID / ⌘7 · App Store
+

Políticas

¿Qué controles hay y están surtiendo efecto? Cada política, su estado real y un historial de actividad unificado.

⌘8 · Developer ID / ⌘7 · App Store

Preguntar

Preguntas en lenguaje corriente, respondidas con el historial, y debajo cada consulta que ejecutó.

⌘6
-

Fuentes

¿Están llegando datos, cuánto tiene nombre y qué canales de dispositivos están activos? Colector, Dispositivos y Cobertura en un solo lugar.

⌘9 · Developer ID / ⌘8 · App Store
+

Fuentes

¿Están llegando datos, qué canales de dispositivos están activos y qué puede contabilizar Flowlight? Una página desde Colector hasta Cobertura.

⌘9 · Developer ID / ⌘8 · App Store

Inspeccionar

Opcional. ¿Qué envió el agente en realidad y qué llamada de herramienta lo provocó? Peticiones descifradas, llamadas de herramientas, resultados.

⌘7 · Developer ID
diff --git a/site/pages/es/privacy.html b/site/pages/es/privacy.html index 3d491fc0..ee8783a3 100644 --- a/site/pages/es/privacy.html +++ b/site/pages/es/privacy.html @@ -56,7 +56,7 @@

Peticiones de red que hace la app

- + diff --git a/site/pages/fr/docs.html b/site/pages/fr/docs.html index bfbc5763..f4953ce1 100644 --- a/site/pages/fr/docs.html +++ b/site/pages/fr/docs.html @@ -30,7 +30,7 @@

Utiliser Flowlight

@@ -101,10 +101,10 @@

Les vues

- - + +
PeticiónEnviada aQué contieneCuándo
Comprobación de actualizacionesapi.github.comUna petición HTTPS normal de la última versión; GitHub ve tu dirección IP y el agente de usuario Flowlight/<version>Al arrancar y a diario, desde la 0.1.2. Interruptor para desactivarla en Ajustes › General
Descarga de la actualizacióngithub.comLa imagen de disco y su archivo de suma de verificaciónSolo cuando pulsas Descargar y abrir
Actualización oficial del paquete de reglasflowlight.xinbetween.comUna solicitud HTTPS estándar al feed oficial fijo. El servidor ve tu dirección IP, el agente de usuario Flowlight/<version> y los validadores condicionales de caché; no recibe tráfico, ajustes de paquetes ni ningún identificador del dispositivoDespués del inicio, cuando corresponda, y luego como máximo una vez al día mientras todo funcione correctamente. Activada de forma predeterminada; interruptor para desactivarla y opción manual Buscar ahora en Reglas › Paquetes
Actualización oficial del paquete de reglasflowlight.xinbetween.comUna solicitud HTTPS estándar al feed oficial fijo. El servidor ve tu dirección IP, el agente de usuario Flowlight/<version> y los validadores condicionales de caché; no recibe tráfico, ajustes de paquetes ni ningún identificador del dispositivoDespués del inicio, cuando corresponda, y luego como máximo una vez al día mientras todo funcione correctamente. Activada de forma predeterminada; interruptor para desactivarla y opción manual Buscar ahora en Políticas › Paquetes gestionados
DNS inversoEl servidor DNS que tengas configuradoUna consulta del nombre registrado de una dirección IP, cuando no se ha visto ningún nombre de hostSegún haga falta, en el modo de captura del muestreador, que es el predeterminado hasta que la extensión de red esté en marcha. Sin interruptor para desactivarla: sin ella el muestreador no puede nombrar la mayoría de las conexiones. La extensión de red las nombra a partir del propio tráfico y no hace esta consulta
Consulta del propietario de la redTeam Cymru, por DNSDirecciones IP públicas sin nombre de host conocido. Las direcciones privadas y reservadas no salen nunca de tu MacActivada por defecto. Interruptor para desactivarla en Captura › Identificar a los propietarios de la red
Preguntar con un modelo alojadoEl proveedor que elijas: Anthropic, OpenAI, Google Gemini o un endpoint compatibleTu pregunta y los resultados que devuelvan las consultas de solo lectura de Flowlight que elija el modelo. La base de datos de tráfico en sí no se subeSolo cuando eliges un proveedor alojado y envías una pregunta. La pantalla Preguntar muestra las peticiones transmitidas
Alertes⌘5Tout ce qui a paru inhabituel, avec l’app, la destination et le chiffre qui a déclenché la règle.
Demander⌘6Des questions sur votre propre trafic en langage courant, auxquelles un modèle local ou hébergé répond à partir de l’historique enregistré.
Inspecter⌘7 · Developer IDFacultatif : les requêtes déchiffrées des agents que vous faites passer par Flowlight, et les appels d’outils qui les ont causées. La recherche va jusque dans les corps de requête et de réponse.
Règles⌘8 · Developer ID
⌘7 · App Store
Chaque liste d’autorisation et chaque refus que vous avez posés, ce que chacun couvre, et ce qu’il a réellement arrêté.
Sources⌘9 · Developer ID
⌘8 · App Store
La santé et la récupération du collecteur, la couverture des noms d’hôte, ainsi que l’activité facultative des caméras, du Bluetooth, de l’USB et du stockage dans trois onglets.
Politiques⌘8 · Developer ID
⌘7 · App Store
Chaque politique configurée, son effet réel et un historique d’activité unifié en lecture seule.
Sources⌘9 · Developer ID
⌘8 · App Store
La santé et la récupération du collecteur, les canaux d’appareils facultatifs et la couverture sur une seule page opérationnelle déroulante.
-

Les raccourcis suivent l’ordre visible de la barre latérale. Les versions Developer ID incluent Inspecter en ⌘7 ; les versions App Store l’omettent, donc Règles et Sources passent à ⌘7 et ⌘8.

+

Les raccourcis suivent l’ordre visible de la barre latérale. Les versions Developer ID incluent Inspecter en ⌘7 ; les versions App Store l’omettent, donc Politiques et Sources passent à ⌘7 et ⌘8.

L’écran En direct : une liste d’applications avec leurs débits d’envoi et de réception actuels et leur destination la plus active.
En direct, l’écran sur lequel Flowlight s’ouvre.
@@ -146,7 +146,7 @@

Outils & serveurs MCP

dit plus que l’un ou l’autre seul, tandis qu’un domaine seulement configuré n’a jamais été joint, et qu’un domaine seulement observé n’était écrit nulle part où vous auriez pu le lire.

Chaque domaine propose deux refus : pour cet agent, ce qui couvre l’agent et les outils qu’il lance, ou - partout, ce qui couvre toutes les apps du Mac. Les deux écrivent une règle ordinaire, si bien que le flux des violations + partout, ce qui couvre toutes les apps du Mac. Les deux écrivent une règle ordinaire, si bien qu’Activité les signale et que l’éditeur de règles les explique — et, parce que ce sont des règles ordinaires, la simulation de l’éditeur vous dira ce que l’une d’elles aurait refusé avant que vous ne l’activiez.

Un type de domaine n’a pas de boutons. Un connecteur que le fournisseur de modèle joint de lui-même — déclaré dans le corps de la requête @@ -337,16 +337,16 @@

Réponses simulées

contactée : une simulation change la réponse, pas la possibilité d’établir la connexion, donc « l’API renvoie 500 » fonctionne alors que « l’API est complètement injoignable » non.

-

Règles

-

Une liste d’autorisation, c’est l’idée qu’un agent se fait des endroits où il peut aller. Une règle en est la version générale : elle nomme un +

Politiques

+

Politiques réunit tous les contrôles que Flowlight peut appliquer dans un même inventaire : règles ordinaires d’autorisation ou de blocage, listes d’autorisation des agents, packs gérés et, lorsque l’inspection HTTPS est disponible, garde-fous, simulations, réécritures de requêtes et transformations de réponses. Chaque ligne distingue l’état configuré de l’effet réel : appliquée, signalement uniquement, surveillée, en attente, programmée, suspendue, utilisée, en cours d’application, à vérifier, indisponible, incompatible ou en échec. La sélection d’une ligne ouvre l’éditeur contextuel auquel cette politique appartient ; Politiques ne remplace ni ne confond leurs différents modèles de consentement et de conservation.

+

Une liste d’autorisation, c’est l’idée qu’un agent se fait des endroits où il peut aller. Une règle ordinaire en est la version générale : elle nomme un sujet — une app, un agent, une destination, une URL, ou un couple formé de deux d’entre eux — et dit bloquer ou autoriser. Faites un clic droit sur n’importe quelle ligne dans En direct, Historique, Agents IA ou Inspecter pour en écrire une sur ce que vous regardez ; - toutes atterrissent dans le même éditeur, sur l’écran Règles, parce qu’une règle qui prendrait effet hors de votre vue - serait une règle que vous ne pourriez plus jamais retrouver.

+ chacune reste accessible dans Politiques et s’ouvre dans son éditeur de référence.

Des modèles. Vingt-deux, en cinq groupes — empêcher un agent d’atteindre des registres de paquets ou d’autres fournisseurs de modèles, fermer les voies habituelles par lesquelles les données partent (sites de partage de texte, collecteurs de webhooks, dépôts de fichiers anonymes, tunnels, DNS over HTTPS), l’analytique produit et les rapports de plantage, les politiques d’horaires de travail, et le « bloque-moi ça une heure, le temps que je comprenne - ce que c’est ». Parcourez-les depuis la barre d’outils de Règles, ou faites un clic droit sur une ligne n’importe où dans En direct, Historique, Agents IA ou Inspecter et + ce que c’est ». Parcourez-les depuis la barre d’outils de Politiques, ou faites un clic droit sur une ligne n’importe où dans En direct, Historique, Agents IA ou Inspecter et appliquez-en un à ce que vous regardez.

Chaque modèle annonce ce qu’il va faire avant de le faire. Tout ce qui écrit plus d’une règle ouvre une fiche listant chaque hôte et chaque horaire ; tout ce qui est permanent ouvre l’éditeur prérempli ; et seules les règles qui se défont d’elles-mêmes — une @@ -367,8 +367,7 @@

Règles

l’adresse et le port, rien des chemins, et uniquement en mode extension. L’inspection HTTPS refuse une requête : elle peut donc viser une URL et répondre par un statut que l’agent sait lire, au lieu d’une socket morte sans explication. L’échantillonneur nettop ne peut rien refuser du tout. Une règle que la configuration actuelle ne peut pas appliquer le dit dans la liste, plutôt que de rester là en faisant semblant.

-

Le second onglet est le flux des violations : chaque connexion qu’une règle a tranchée, avec l’app, sa destination, quelle - règle a tranché et s’il s’agissait d’un refus ou d’une exception qui l’a laissée passer.

+

La seconde vue est Activité, en lecture seule : elle réunit dans une même chronologie les décisions des règles ordinaires, les violations de listes d’autorisation, les événements des packs gérés et les effets distincts des politiques d’inspection. Elle adapte les enregistrements de référence existants au lieu de réécrire l’historique dans un modèle d’événement générique ; les versions App Store omettent les types de politiques dépendant de l’inspection et leur activité conservée.

Ce qu’elle aurait fait. Une règle de blocage est facile à écrire et effrayante à activer, parce que la première chose que l’on apprend d’ordinaire à son sujet, c’est ce qu’elle a cassé — et on l’apprend plus tard, parce que quelque chose @@ -389,7 +388,7 @@

Règles

d’historique a été testée sur deux heures d’historique.

Packs gérés de publicité et de traqueurs

-

Règles › Packs contient trois listes officielles de noms d’hôte : Publicité, Suivi intersites et Confidentialité et télémétrie. Elles sont visibles dès le départ et toutes désactivées par défaut. Le blocage nécessite l’Extension réseau ; l’échantillonneur nettop ne peut qu’observer.

+

Politiques › Packs gérés contient trois listes officielles de noms d’hôte : Publicité, Suivi intersites et Confidentialité et télémétrie. Elles sont visibles dès le départ et toutes désactivées par défaut. Le blocage nécessite l’Extension réseau ; l’échantillonneur nettop ne peut qu’observer.

Uniquement des mises à jour officielles et signées. Flowlight consulte une source HTTPS fixe unique pour rechercher des catalogues plus récents signés par l’éditeur. Les vérifications automatiques sont activées par défaut et peuvent être désactivées ici. Une signature authentifie le contenu, mais ne l’active pas : un nouveau pack reste désactivé et, si les destinations changent, l’application d’un pack activé est suspendue jusqu’à ce que vous examiniez la nouvelle version exacte et appuyiez sur Enregistrer. Pendant une panne, le contenu accepté peut rester disponible comme dernière version valide connue, visiblement signalée comme obsolète. Il s’agit de blocages réseau au niveau des noms d’hôte : ils ne peuvent pas supprimer les vides dans le DOM ou la mise en page, les superpositions du navigateur, les fenêtres contextuelles générées par des scripts ni les fenêtres d’applications natives.

Quand l’inspection HTTPS achemine une app par le proxy de Flowlight, celui-ci décide des règles de destination ordinaires et des packs gérés activés d’après l’identité d’origine de l’app, avant d’ouvrir toute connexion au serveur d’origine. Pour les tunnels CONNECT épinglés ou jamais déchiffrés, cela fonctionne par nom d’hôte ; les listes d’autorisation des agents restent limitées au chemin direct par l’Extension réseau. Si le propriétaire ou le nom d’hôte est inconnu, la connexion est autorisée.

@@ -438,8 +437,8 @@

Garde-fous des agents : bloquer un outil, un serveur ou

Ce n’est pas un bac à sable. Un outil refusé cesse d’être proposé et cesse d’obtenir une réponse. Un agent qui a encore un shell peut faire à la main ce que l’outil aurait fait. Flowlight l’enregistrera ; il ne prétend pas l’empêcher.

-

Sources : collecteur, couverture et appareils

-

Sources réunit la configuration opérationnelle. Son résumé de santé indique si le trafic arrive, quelle part est nommée et quels canaux d’appareils facultatifs sont actifs ; toute étape inachevée mène directement à Sources › Collecteur.

+

Sources : collecteur, appareils et couverture

+

Sources est une seule page opérationnelle déroulante : d’abord Collecteur, puis Appareils et Couverture dans des sections clairement titrées. Les avis d’état, les réponses de Demander, les fenêtres restaurées et les liens des captures d’écran font défiler jusqu’à la section concernée et la mettent brièvement en évidence sans masquer les autres.

Flowlight enregistre le trafic de deux façons, au choix dans Sources › Collecteur. Elles diffèrent par ce qu’elles peuvent manquer, ce qu’il vaut la peine de comprendre avant de compter sur l’une ou l’autre.

@@ -637,7 +636,7 @@

Questions & réponses

Fenêtre fermée, Flowlight ne fait presque rien : il tient à jour les débits de la barre des menus et écrit une ligne par seconde. L’extension réseau coûte moins que l’échantillonneur de secours, qui lance un processus nettop éphémère chaque seconde.
Peut-il bloquer des connexions ?
-
Oui, avec l’extension réseau. La liste d’autorisation d’un agent peut refuser autant qu’avertir, une règle peut bloquer une app, +
Oui, avec l’extension réseau. La liste d’autorisation d’un agent peut refuser autant qu’avertir, une règle peut bloquer une app, une destination ou une URL aussi longtemps que vous le dites, et un garde-fou peut retirer un outil à un agent avant que son modèle ne le voie. Rien ne bloque avant que vous le demandiez, et chaque refus porte sa sortie de secours.
Ai-je besoin de l’extension réseau ?
diff --git a/site/pages/fr/index.html b/site/pages/fr/index.html index 18ae3aab..dc2b9f1a 100644 --- a/site/pages/fr/index.html +++ b/site/pages/fr/index.html @@ -110,11 +110,11 @@

Historique

Où tout cela est-il parti ? De la seconde à l’ann

Alertes

Qu’est-ce qui clochait ? Des alertes explicables, par app, avec la destination et le seuil.

⌘5
-

Règles

Qu’ai-je refusé, et est-ce bien appliqué ? Chaque règle, ce qu’elle a bloqué, et le flux de toutes les décisions.

⌘8 · Developer ID / ⌘7 · App Store
+

Politiques

Quels contrôles existent, et sont-ils effectifs ? Chaque politique, son état réel et un historique d’activité unifié.

⌘8 · Developer ID / ⌘7 · App Store

Demander

Des questions en langage courant, auxquelles l’historique répond — avec la liste des requêtes exécutées juste en dessous.

⌘6
-

Sources

Les données arrivent-elles, quelle part est nommée et quels canaux d’appareils sont actifs ? Collecteur, Appareils et Couverture au même endroit.

⌘9 · Developer ID / ⌘8 · App Store
+

Sources

Les données arrivent-elles, quels canaux d’appareils sont actifs et de quoi Flowlight peut-il rendre compte ? Une seule page, du Collecteur à la Couverture.

⌘9 · Developer ID / ⌘8 · App Store

Inspecter

Facultatif. Qu’a réellement envoyé l’agent, et quel appel d’outil en est la cause ? Requêtes déchiffrées, appels d’outils, résultats.

⌘7 · Developer ID
diff --git a/site/pages/fr/privacy.html b/site/pages/fr/privacy.html index 34ae0a0b..6b2cbf4e 100644 --- a/site/pages/fr/privacy.html +++ b/site/pages/fr/privacy.html @@ -56,7 +56,7 @@

Les requêtes réseau que fait l’app

- + diff --git a/site/pages/index.html b/site/pages/index.html index 6251dd2e..7b412f6c 100644 --- a/site/pages/index.html +++ b/site/pages/index.html @@ -119,11 +119,11 @@

History

Where did it all go? From 15 minutes through all history, gro

Alerts

What looked wrong? Explainable, per-app alerts with the destination and the threshold.

⌘5
-

Rules

What have I refused, and is it happening? Every rule, what each has blocked, and a feed of every decision.

⌘8 · Developer ID / ⌘7 · App Store
+

Policies

What controls exist, and are they effective? Every policy, its real state, and a unified activity trail.

⌘8 · Developer ID / ⌘7 · App Store

Ask

Questions in plain language, answered from the history — with every query it ran listed underneath.

⌘6
-

Sources

Is data arriving, how much is named, and which device channels are active? Collector, Devices and Coverage in one place.

⌘9 · Developer ID / ⌘8 · App Store
+

Sources

Is data arriving, which device channels are active, and what can Flowlight account for? One page: Collector, Devices, then Coverage.

⌘9 · Developer ID / ⌘8 · App Store

Inspect

Optional. What did the agent actually send, and which tool call made it? Decrypted requests, tool calls, results.

⌘7 · Developer ID
diff --git a/site/pages/it/docs.html b/site/pages/it/docs.html index ede0992a..d476052f 100644 --- a/site/pages/it/docs.html +++ b/site/pages/it/docs.html @@ -30,7 +30,7 @@

Usare Flowlight

@@ -101,10 +101,10 @@

Le viste

- - + +
RequêteEnvoyée àCe qu’elle contientQuand
Recherche de mise à jourapi.github.comUne requête HTTPS standard pour la dernière version ; GitHub voit votre adresse IP et l’agent utilisateur Flowlight/<version>Au lancement et une fois par jour, depuis 0.1.2. Interrupteur d’arrêt dans Réglages › Général
Téléchargement de mise à jourgithub.comL’image disque et son fichier de somme de contrôleSeulement quand vous cliquez sur Télécharger & ouvrir
Mise à jour du pack de règles officielflowlight.xinbetween.comUne requête HTTPS standard vers le flux officiel fixe. Le serveur voit votre adresse IP, l’agent utilisateur Flowlight/<version> et les validateurs conditionnels du cache ; il ne reçoit ni trafic, ni réglages des packs, ni identifiant d’appareilAprès le démarrage, lorsqu’une vérification est due, puis au maximum une fois par jour tant que le service fonctionne normalement. Activée par défaut ; interrupteur de désactivation et option manuelle Rechercher maintenant dans Règles › Packs
Mise à jour du pack de règles officielflowlight.xinbetween.comUne requête HTTPS standard vers le flux officiel fixe. Le serveur voit votre adresse IP, l’agent utilisateur Flowlight/<version> et les validateurs conditionnels du cache ; il ne reçoit ni trafic, ni réglages des packs, ni identifiant d’appareilAprès le démarrage, lorsqu’une vérification est due, puis au maximum une fois par jour tant que le service fonctionne normalement. Activée par défaut ; interrupteur de désactivation et option manuelle Rechercher maintenant dans Politiques › Packs gérés
DNS inverseLe résolveur DNS que vous avez configuréUne recherche du nom enregistré d’une adresse IP, quand aucun nom d’hôte n’a été observéAu besoin, dans le mode de capture par échantillonnage, qui est celui par défaut jusqu’à ce que l’extension réseau tourne. Pas d’interrupteur d’arrêt : sans elle, l’échantillonneur ne peut pas nommer la plupart des connexions. L’extension réseau les nomme à partir du trafic lui-même et ne fait pas cette recherche
Recherche du propriétaire de réseauTeam Cymru, par DNSDes adresses IP publiques sans nom d’hôte connu. Les adresses privées et réservées ne quittent jamais votre MacActivé par défaut. Interrupteur d’arrêt dans Sources › Collecteur › Identifier les propriétaires de réseau
Demander avec un modèle hébergéLe fournisseur que vous choisissez : Anthropic, OpenAI, Google Gemini ou un point de terminaison compatibleVotre question et les résultats renvoyés par les requêtes Flowlight en lecture seule choisies par le modèle. La base de données de trafic elle-même n’est pas envoyéeSeulement quand vous choisissez un fournisseur hébergé et soumettez une question. L’écran Demander montre les requêtes transmises
Avvisi⌘5Tutto ciò che è sembrato insolito, con l'app, la destinazione e il numero che ha fatto scattare la regola.
Chiedi⌘6Domande sul tuo traffico in linguaggio naturale, a cui un modello locale o ospitato risponde a partire dalla cronologia registrata.
Ispeziona⌘7 · Developer IDOpzionale: le richieste decifrate degli agenti che instradi attraverso Flowlight e le chiamate agli strumenti che le hanno prodotte. La ricerca entra nei corpi di richieste e risposte.
Regole⌘8 · Developer ID
⌘7 · App Store
Ogni elenco consentiti e ogni rifiuto che hai impostato, cosa copre ciascuno e cosa ha fermato davvero.
Origini⌘9 · Developer ID
⌘8 · App Store
Stato del Collector, canali dei dispositivi e copertura dei nomi in tre schede.
Criteri⌘8 · Developer ID
⌘7 · App Store
Ogni criterio configurato, se è davvero effettivo e una cronologia unificata delle attività in sola lettura.
Origini⌘9 · Developer ID
⌘8 · App Store
Stato e ripristino del Collector, canali facoltativi dei dispositivi e copertura in un'unica pagina operativa a scorrimento.
-

Le scorciatoie seguono l'ordine visibile della barra laterale. Le versioni Developer ID includono Ispeziona su ⌘7; quelle App Store lo omettono, quindi Regole e Origini passano a ⌘7 e ⌘8.

+

Le scorciatoie seguono l'ordine visibile della barra laterale. Le versioni Developer ID includono Ispeziona su ⌘7; quelle App Store lo omettono, quindi Criteri e Origini passano a ⌘7 e ⌘8.

La schermata In diretta: un elenco di applicazioni con le loro velocità attuali di invio e ricezione e la destinazione più trafficata.
In diretta, la schermata con cui Flowlight si apre.
@@ -335,16 +335,20 @@

Risposte simulate

contattata: una simulazione cambia la risposta, non la possibilità di connettersi, quindi «l'API restituisce 500» funziona mentre «l'API è del tutto irraggiungibile» no.

-

Regole

-

Un elenco consentiti è l'idea che un singolo agente ha di dove può andare. Una regola è la versione generale: indica un +

Criteri

+

Criteri riunisce in un solo inventario tutti i controlli che Flowlight può applicare: normali regole di consenso o blocco, elenchi consentiti + degli agenti, pacchetti gestiti e, dove è disponibile l'ispezione HTTPS, protezioni, simulazioni, riscritture delle richieste e trasformazioni delle + risposte. Ogni riga distingue lo stato configurato dall'effetto reale: applicazione attiva, sola segnalazione, monitoraggio, attesa, pianificato, + in pausa, esaurito, in corso di applicazione, revisione necessaria, non disponibile, incompatibile o non riuscito. Selezionando una riga si apre l'editor + contestuale responsabile di quel criterio; Criteri non sostituisce né confonde i distinti modelli di consenso e persistenza.

+

Un elenco consentiti è l'idea che un singolo agente ha di dove può andare. Una normale regola è la versione generale: indica un soggetto — un'app, un agente, una destinazione, un URL o una coppia di due di questi — e dice blocca o consenti. Fai clic con il tasto destro su una riga in In diretta, Cronologia, Agenti IA o Ispeziona per scriverne una su ciò che stai guardando; - tutte finiscono nello stesso editor della schermata Regole, perché una regola che entra in vigore fuori dalla vista - sarebbe una regola che non potresti più ritrovare.

+ ognuna resta reperibile in Criteri e si apre nel proprio editor autorevole.

Modelli. Ventidue, in cinque gruppi: impedire a un agente di raggiungere i registri di pacchetti o altri provider di modelli, chiudere le vie più comuni per cui i dati escono (siti di paste, raccoglitori di webhook, servizi anonimi per caricare file, tunnel, DNS over HTTPS), analisi di prodotto e segnalazione degli arresti anomali, politiche sull'orario di lavoro, e il classico «blocca questo per un'ora mentre capisco - cos'è». Sfogliali dalla barra strumenti di Regole, oppure fai clic con il tasto destro su una riga in In diretta, Cronologia, Agenti IA o Ispeziona e + cos'è». Sfogliali dalla barra strumenti di Criteri, oppure fai clic con il tasto destro su una riga in In diretta, Cronologia, Agenti IA o Ispeziona e applicane uno a ciò che stai guardando.

Ogni modello dice cosa farà prima di farlo. Tutto ciò che scrive più di una regola apre una scheda con l'elenco di ogni host e orario; tutto ciò che è permanente apre l'editor già compilato; e solo le regole che si annullano da sole — un'ora, @@ -365,8 +369,9 @@

Regole

l'indirizzo e la porta, nulla dei percorsi, e solo in modalità estensione. L'ispezione HTTPS rifiuta una singola richiesta, quindi può corrispondere a un URL e rispondere con uno stato che l'agente può leggere, invece di un socket morto senza spiegazioni. Il campionatore nettop non può rifiutare nulla. Una regola che la configurazione attuale non può applicare lo dice nell'elenco, invece di starsene lì con l'aria di essere in vigore.

-

Il secondo pannello è il flusso delle violazioni: ogni connessione decisa da una regola, con l'app, dove era diretta, quale - regola ha deciso e se si è trattato di un rifiuto o di un'eccezione che l'ha lasciata passare.

+

La seconda vista è Attività, in sola lettura: riunisce in un'unica cronologia le decisioni delle regole comuni, le violazioni degli elenchi consentiti, + gli eventi dei pacchetti gestiti e gli effetti distinti dei criteri di ispezione. Usa i record originali, ciascuno autorevole nel proprio ambito, invece di + riscrivere la cronologia in un unico modello generico di evento; le versioni Store omettono i tipi di criterio che dipendono dall'ispezione e le relative attività conservate.

Cosa avrebbe fatto. Una regola di blocco è facile da scrivere e spaventosa da attivare, perché la prima cosa che normalmente scopri di una regola così è che cosa ha rotto — e lo scopri dopo, da qualcosa che smette di funzionare altrove. @@ -387,7 +392,7 @@

Regole

testata su due ore di storico.

Pacchetti gestiti per pubblicità e tracker

-

Regole › Pacchetti contiene tre elenchi ufficiali di nomi host: Pubblicità, Tracciamento tra siti e Privacy e telemetria. Sono visibili fin dall’inizio e tutti disattivati per impostazione predefinita. Il blocco richiede l’Estensione di rete; il campionatore nettop può solo osservare.

+

Criteri › Pacchetti gestiti contiene tre elenchi ufficiali di nomi host: Pubblicità, Tracciamento tra siti e Privacy e telemetria. Sono visibili fin dall’inizio e tutti disattivati per impostazione predefinita. Il blocco richiede l’Estensione di rete; il campionatore nettop può solo osservare.

Solo aggiornamenti ufficiali e firmati. Flowlight controlla un’unica fonte HTTPS fissa per trovare cataloghi più recenti firmati dall’editore. I controlli automatici sono attivi per impostazione predefinita e possono essere disattivati qui. Una firma autentica il contenuto, ma non lo attiva: un nuovo pacchetto resta disattivato e, se cambiano le destinazioni, l’applicazione di un pacchetto attivo viene sospesa finché non esamini l’esatta nuova versione e premi Salva. Durante un’interruzione, i contenuti accettati possono restare disponibili come ultima versione valida nota, visibilmente indicata come obsoleta. Si tratta di blocchi di rete a livello di nome host: non possono eliminare spazi vuoti nel DOM o nel layout, sovrapposizioni del browser, popup generati da script o finestre di app native.

Quando l’ispezione HTTPS instrada un’app attraverso il proxy di Flowlight, questo decide le normali regole di destinazione e i pacchetti gestiti attivi usando l’identità originale dell’app prima di aprire qualsiasi connessione al server di origine. Per i tunnel CONNECT con pinning o mai decifrati, ciò funziona in base al nome host; gli elenchi consentiti degli agenti restano limitati al percorso diretto attraverso l’Estensione di rete. Se il proprietario o il nome host è sconosciuto, la connessione viene consentita.

@@ -436,7 +441,10 @@

Protezioni per gli agenti: bloccare uno strumento, un server

Questa non è una sandbox. Uno strumento rifiutato non viene più offerto e non riceve più risposta. Un agente che ha ancora una shell può fare a mano ciò che avrebbe fatto lo strumento. Flowlight lo registra; non pretende di impedirlo.

-

Origini: Collector e motori

+

Origini: Collector, dispositivi e copertura

+

Origini è un'unica pagina operativa a scorrimento: prima il Collector, poi Dispositivi e Copertura come sezioni chiaramente + contrassegnate. Gli avvisi di stato, le risposte di Chiedi, le finestre ripristinate e i collegamenti usati per gli screenshot scorrono fino alla sezione pertinente + e la mettono brevemente in evidenza senza nascondere le altre.

Flowlight registra il traffico in uno di due modi, da scegliere in Origini › Collector. Differiscono per ciò che possono perdere, e vale la pena capirlo prima di affidarsi all'uno o all'altro.

@@ -634,7 +642,7 @@

Domande e risposte

Con la finestra chiusa Flowlight fa quasi nulla: mantiene le velocità nella barra dei menu e scrive una riga al secondo. L'estensione di rete costa meno del campionatore di riserva, che avvia un breve processo nettop ogni secondo.
Può bloccare le connessioni?
-
Sì, con l'estensione di rete. L'elenco consentiti di un agente può rifiutare oltre che avvisare, una regola può bloccare un'app, +
Sì, con l'estensione di rete. L'elenco consentiti di un agente può rifiutare oltre che avvisare, una regola può bloccare un'app, una destinazione o un URL per il tempo che dici tu, e una protezione può togliere uno strumento a un agente prima che venga offerto al suo modello. Nulla blocca finché non glielo chiedi, e ogni rifiuto porta con sé la sua via d'uscita.
Mi serve l'estensione di rete?
diff --git a/site/pages/it/index.html b/site/pages/it/index.html index 62f4c544..7bb0f3a6 100644 --- a/site/pages/it/index.html +++ b/site/pages/it/index.html @@ -110,11 +110,11 @@

Cronologia

Dove è finito tutto? Dal secondo all'anno, per app, desti

Avvisi

Cosa non tornava? Avvisi per app, spiegati, con la destinazione e la soglia.

⌘5
-

Regole

Cosa ho rifiutato, e sta funzionando? Ogni regola, che cosa ha bloccato e il flusso di tutte le decisioni.

⌘8 · Developer ID / ⌘7 · App Store
+

Criteri

Quali controlli esistono, e sono effettivi? Ogni criterio, il suo stato reale e una cronologia unificata delle attività.

⌘8 · Developer ID / ⌘7 · App Store

Chiedi

Domande in linguaggio naturale, con risposte tratte dalla cronologia e sotto l'elenco di tutte le query eseguite.

⌘6
-

Origini

I dati stanno arrivando, quanti hanno un nome e quali canali dei dispositivi sono attivi? Collector, Dispositivi e Copertura in un unico posto.

⌘9 · Developer ID / ⌘8 · App Store
+

Origini

I dati stanno arrivando, quali canali dei dispositivi sono attivi e che cosa può contabilizzare Flowlight? Un'unica pagina, dal Collector alla Copertura.

⌘9 · Developer ID / ⌘8 · App Store

Ispeziona

Opzionale. Cosa ha inviato davvero l'agente, e quale chiamata a uno strumento l'ha prodotta? Richieste decifrate, chiamate agli strumenti, risultati.

⌘7 · Developer ID
diff --git a/site/pages/it/privacy.html b/site/pages/it/privacy.html index 7dc7a416..67dde5bf 100644 --- a/site/pages/it/privacy.html +++ b/site/pages/it/privacy.html @@ -56,7 +56,7 @@

Richieste di rete che fa l'app

- + diff --git a/site/pages/ja/docs.html b/site/pages/ja/docs.html index 4d22a593..d20ddb6e 100644 --- a/site/pages/ja/docs.html +++ b/site/pages/ja/docs.html @@ -30,7 +30,7 @@

Flowlight の使い方

@@ -99,10 +99,10 @@

各ビュー

- - + +
RichiestaInviata aCosa contieneQuando
Controllo aggiornamentiapi.github.comUna normale richiesta HTTPS per l'ultima versione; GitHub vede il tuo indirizzo IP e lo user agent Flowlight/<version>All'avvio e una volta al giorno, dalla 0.1.2. Interruttore per disattivarla in Impostazioni › Generali
Download dell'aggiornamentogithub.comL'immagine disco e il suo file di checksumSolo quando fai clic su Scarica e apri
Aggiornamento ufficiale del pacchetto di regoleflowlight.xinbetween.comUna richiesta HTTPS standard al feed ufficiale fisso. Il server vede il tuo indirizzo IP, lo user agent Flowlight/<version> e i validatori condizionali della cache; non riceve traffico, impostazioni dei pacchetti o identificatori del dispositivoDopo l’avvio, quando previsto, e successivamente non più di una volta al giorno durante il normale funzionamento. Attivo per impostazione predefinita; interruttore per disattivarlo e opzione manuale Controlla ora in Regole › Pacchetti
Aggiornamento ufficiale del pacchetto di regoleflowlight.xinbetween.comUna richiesta HTTPS standard al feed ufficiale fisso. Il server vede il tuo indirizzo IP, lo user agent Flowlight/<version> e i validatori condizionali della cache; non riceve traffico, impostazioni dei pacchetti o identificatori del dispositivoDopo l’avvio, quando previsto, e successivamente non più di una volta al giorno durante il normale funzionamento. Attivo per impostazione predefinita; interruttore per disattivarlo e opzione manuale Controlla ora in Criteri › Pacchetti gestiti
DNS inversoIl resolver DNS che hai configuratoUna ricerca del nome registrato di un indirizzo IP, quando non è stato rilevato alcun nome hostQuando serve, nella modalità di acquisizione a campionamento, che è quella predefinita finché l'estensione di rete non è in funzione. Nessun interruttore per disattivarla: senza di essa il campionatore non può dare un nome alla maggior parte delle connessioni. L'estensione di rete le nomina dal traffico stesso e non fa questa ricerca
Ricerca del proprietario della reteTeam Cymru, via DNSIndirizzi IP pubblici senza nome host noto. Gli indirizzi privati e riservati non lasciano mai il tuo MacAttiva per impostazione predefinita. Interruttore per disattivarla in Acquisizione › Identifica i proprietari della rete
Chiedi con un modello ospitatoIl provider che selezioni: Anthropic, OpenAI, Google Gemini o un endpoint compatibileLa tua domanda e i risultati restituiti dalle query di sola lettura di Flowlight selezionate dal modello. Il database del traffico non viene caricatoSolo quando selezioni un provider ospitato e invii una domanda. La schermata Chiedi mostra le richieste trasmesse
アラート⌘5いつもと違って見えたものすべて。アプリ、接続先、そしてルールのしきい値を超えた数値とともに。
質問⌘6自分のトラフィックについての、ふつうの言葉での質問。記録された履歴から、ローカルまたはホスト型のモデルが答えます。
インスペクト⌘7 · Developer ID任意。Flowlight を経由させたエージェントの復号済みリクエストと、その裏にあるツール呼び出し。検索はリクエストとレスポンスの本文にも届きます。
ルール⌘8 · Developer ID
⌘7 · App Store
設定したすべての許可リストと拒否。それぞれが何を対象にし、実際に何を止めたか。
ソース⌘9 · Developer ID
⌘8 · App Store
コレクターの状態、デバイスのチャンネル、名前付けのカバー範囲を 3 つのタブで表示。
ポリシー⌘8 · Developer ID
⌘7 · App Store
設定されたすべてのポリシー、その実際の適用状況、そして統合された読み取り専用のアクティビティ履歴。
ソース⌘9 · Developer ID
⌘8 · App Store
コレクターの状態と復旧、任意で有効にするデバイスチャンネル、カバー範囲を、1 つのスクロール可能な運用ページに表示。
-

ショートカットは表示中のサイドバー順です。Developer ID 版は ⌘7 にインスペクトがあります。App Store 版では省かれるため、ルールとソースは ⌘7 と ⌘8 になります。

+

ショートカットは表示中のサイドバー順です。Developer ID 版は ⌘7 にインスペクトがあります。App Store 版では省かれるため、ポリシーとソースは ⌘7 と ⌘8 になります。

ライブビュー。アプリの一覧に、現在の送信と受信の速度、そして最も忙しい接続先が並んでいます。
ライブ。Flowlight が最初に開く画面です。
@@ -142,7 +142,7 @@

ツールと MCP サーバ

書かれていてしかも実際に接続もあったということは、どちらか一方だけよりも多くを語るからです。設定されているだけの ドメインには一度も届いておらず、観測されただけのドメインは、前もって読めるどこにも書かれていませんでした。

ドメインごとに拒否の仕方が二つあります。このエージェントに対してはエージェントとそれが起動するツールを覆い、どこでもは - この Mac のすべてのアプリを覆います。どちらもふつうのルールを書きます。ですから違反の履歴がそれを報告し、ルールのエディタが + この Mac のすべてのアプリを覆います。どちらもふつうのルールを書きます。ですからアクティビティがそれを報告し、ルールのエディタが それを説明します。そしてふつうのルールなので、有効にする前に、エディタのシミュレーションがそのルールなら何を拒否していたかを 教えてくれます。

ボタンの付かないドメインが一種類あります。モデルプロバイダが自分で接続するコネクタです(リクエストの本文で宣言され、 @@ -314,14 +314,19 @@

モック応答

接続先への接続そのものは行われるということです。モックが変えるのは答えであって、接続できるかどうかではありません。つまり「API が 500 を返す」は再現できますが、 「API にまったく到達できない」は再現できません。

-

ルール

-

許可リストは、1 つのエージェントについて「どこへ行ってよいか」を定めたものです。ルールはその一般版で、 +

ポリシー

+

ポリシーは、Flowlight が適用できるすべての制御をまとめた一覧です。通常の許可・ブロックルール、エージェントの + 許可リスト、管理対象パック、さらに HTTPS インスペクションを利用できる場合は、ガードレール、モック、リクエストの書き換え、レスポンスの + 変換も含まれます。各行では、設定された状態と実際の効果を区別し、適用中、報告のみ、監視中、待機中、スケジュール待ち、一時停止、使用済み、 + 適用処理中、要確認、利用不可、互換性なし、失敗のいずれかを示します。行を選ぶと、そのポリシーを管理する本来のエディタが開きます。 + ポリシー画面が、それぞれ異なる同意や保存の仕組みを置き換えたり、曖昧にしたりすることはありません。

+

許可リストは、1 つのエージェントについて「どこへ行ってよいか」を定めたものです。通常のルールはその一般版で、 対象(アプリ、エージェント、接続先、URL、またはそのうち 2 つの組み合わせ)を指定し、ブロックか 許可を決めます。ライブ、履歴、AI エージェント、インスペクトのどの行でも右クリックすれば、いま見ているものについてルールを書けます。 - そのどれもがルール画面の同じエディタに集まります。見えないところで効き始めるルールは、二度と見つけられないルールになってしまうからです。

+ どこから作成したルールもポリシー画面で見つけられ、選ぶとそのルールを管理する本来のエディタが開きます。

テンプレート。22 種類あり、5 つのグループに分かれています。エージェントがパッケージレジストリやほかのモデルプロバイダへ届かないようにするもの、 データが出ていきやすい経路をふさぐもの(ペーストサイト、Webhook の受け口、匿名のファイル置き場、トンネル、DNS over HTTPS)、プロダクト分析とクラッシュレポート、 - 勤務時間のポリシー、そして「作業のあいだ 1 時間だけこれをブロックして、何なのか調べる」という単発のものです。ルールのツールバーから一覧を見るか、 + 勤務時間のポリシー、そして「作業のあいだ 1 時間だけこれをブロックして、何なのか調べる」という単発のものです。ポリシーのツールバーから一覧を見るか、 ライブ、履歴、AI エージェント、インスペクトのどこかで行を右クリックして、いま見ているものに当てはめてください。

どのテンプレートも、実行する前に何をするかを述べます。2 つ以上のルールを書くものは、すべてのホストとスケジュールを並べたカードを開きます。恒久的なものは あらかじめ入力されたエディタを開きます。そして自分で元に戻るルールだけ(1 時間、またはこの回の Flowlight の実行中だけ)が、先に示されることなく @@ -339,8 +344,9 @@

ルール

しかも機能拡張モードのときだけです。HTTPS インスペクションは 1 つのリクエストを拒否するので、URL に一致させられますし、理由も告げずに死んだソケットの代わりに、 エージェントが読めるステータスで応答できます。nettop サンプラーは何も拒否できません。現在の構成では実行できないルールは、効いているように見せかけて並ぶのではなく、 一覧にそう表示されます。

-

2 つめのタブは違反の履歴です。ルールが判断したすべての接続について、アプリ、向かおうとしていた先、どのルールが判断したか、 - そしてそれが拒否だったのか、通過を許した例外だったのかが並びます。

+

2 つめのビューは読み取り専用のアクティビティです。通常のルールによる判断、許可リスト違反、管理対象パックのイベント、 + インスペクションポリシー固有の効果を、1 つの時系列にまとめます。すでにある正式な記録をそのまま参照し、履歴を汎用イベントへ + 書き換えることはありません。App Store 版では、インスペクションに依存するポリシーの種類と、それに保存されたアクティビティは表示されません。

このルールが何をしていたか。ブロックのルールは、書くのは簡単でも、有効にするのは怖いものです。そのルールについて最初に分かるのは、 ふつう何を壊したかであり、しかもそれが分かるのは後になってから、どこか別の場所で何かが失敗したときだからです。ルールのエディタは、そこに先に答えます。 @@ -358,7 +364,7 @@

ルール

履歴でテストしたルールは、2 時間分の履歴でテストしたということです。

管理対象の広告・トラッカーパック

-

ルール › パックには、広告、サイト横断トラッキング、プライバシーとテレメトリという3つの公式ホスト名リストがあります。これらは最初から表示され、すべてデフォルトでオフになっています。ブロックにはネットワーク機能拡張が必要です。nettop サンプラーは監視のみ可能です。

+

ポリシー › 管理対象パックには、広告、サイト横断トラッキング、プライバシーとテレメトリという3つの公式ホスト名リストがあります。これらは最初から表示され、すべてデフォルトでオフになっています。ブロックにはネットワーク機能拡張が必要です。nettop サンプラーは監視のみ可能です。

公式の署名付きアップデートのみ。 Flowlight は、公開元が署名した新しいカタログがないか、1つの固定 HTTPS ソースを確認します。自動確認はデフォルトでオンになっており、ここでオフにできます。署名はコンテンツを認証しますが、有効化はしません。新しいパックはオフのままで、宛先が変更された場合は、正確な新バージョンを確認して保存を押すまで、有効なパックの適用が停止されます。障害中も、承認済みコンテンツが、古いことを明示した既知の正常な最終版として保持される場合があります。これらはホスト名単位のネットワークブロックです。DOM やレイアウトの空白、ブラウザのオーバーレイ、スクリプトによるポップアップ、ネイティブアプリのウインドウは除去できません。

HTTPS インスペクションがアプリを Flowlight のプロキシ経由にすると、接続先の通常ルールと有効な管理対象パックは、接続先サーバへの接続が開かれる前に、元のアプリの識別情報を使って判定されます。証明書がピン留めされている、または復号されない CONNECT トンネルでも、ホスト名によって判定できます。エージェントの許可リストは引き続き、ネットワーク機能拡張を直接通る経路だけに適用されます。所有者またはホスト名が不明な場合は、接続を許可します。

@@ -401,7 +407,10 @@

エージェントのガードレール:ツール、サー

これはサンドボックスではありません。拒否したツールは提示されなくなり、呼び出しにも応答しなくなります。しかしシェルを使えるエージェントは、 そのツールがしていたことを手作業で行えます。Flowlight はそれを記録しますが、防げるふりはしません。

-

ソース:コレクターとエンジン

+

ソース:コレクター、デバイス、カバー範囲

+

ソースは、コレクター、デバイス、カバー範囲の順に、見出しで区切って並べた 1 つのスクロール可能な運用ページです。 + 状況の通知、質問への回答、復元されたウインドウ、スクリーンショットからのリンクは、ほかのセクションを隠さず、該当するセクションまで + スクロールして短時間強調表示します。

Flowlight がトラフィックを記録する方法は 2 つあり、ソース › コレクターで選びます。何を取りこぼしうるかが違うので、どちらかに頼る前に 知っておく価値があります。

@@ -584,7 +593,7 @@

質問と回答

ウインドウを閉じているあいだ、Flowlight はほとんど何もしません。メニューバーの速度を保ち、1 秒に 1 行書き込むだけです。ネットワーク機能拡張のほうが、 1 秒ごとに短命な nettop プロセスを起動する代替のサンプラーよりも負荷が低いです。
接続をブロックできますか?
-
はい、ネットワーク機能拡張があれば。エージェントの許可リストは警告だけでなく拒否もでき、ルールはアプリ、接続先、URL を +
はい、ネットワーク機能拡張があれば。エージェントの許可リストは警告だけでなく拒否もでき、ルールはアプリ、接続先、URL を 好きな期間ブロックでき、ガードレールはモデルに提示される前にエージェントからツールを取り上げられます。 あなたが指示するまで何もブロックされず、どの拒否にも抜け道が添えてあります。
ネットワーク機能拡張は必要ですか?
diff --git a/site/pages/ja/index.html b/site/pages/ja/index.html index b6fdc3c9..8204db10 100644 --- a/site/pages/ja/index.html +++ b/site/pages/ja/index.html @@ -110,11 +110,11 @@

履歴

結局どこへ行った?秒から年まで、アプリ・

アラート

何がおかしかった?理由がわかるアプリ単位のアラートに、接続先としきい値を添えて。

⌘5
-

ルール

何を拒否し、それは効いている?すべてのルール、それぞれがブロックしたもの、判定の履歴。

⌘8 · Developer ID / ⌘7 · App Store
+

ポリシー

どんな制御があり、実際に効いている?すべてのポリシー、その実際の状態、統合されたアクティビティ履歴。

⌘8 · Developer ID / ⌘7 · App Store

質問

ふつうの言葉で聞けば、履歴から答えます。実行したクエリもすべて下に並びます。

⌘6
-

ソース

データは届いているか、どれだけ名前が付いているか、どのデバイス経路が有効か。コレクター、デバイス、カバー範囲を 1 か所に。

⌘9 · Developer ID / ⌘8 · App Store
+

ソース

データは届いているか、どのデバイス経路が有効か、Flowlight はどこまで把握できるか。コレクターからカバー範囲までを 1 ページに。

⌘9 · Developer ID / ⌘8 · App Store

インスペクト

任意。エージェントは実際に何を送り、どのツール呼び出しが原因だった?復号したリクエスト、ツール呼び出し、結果。

⌘7 · Developer ID
diff --git a/site/pages/ja/privacy.html b/site/pages/ja/privacy.html index 0d86c38f..c3b87ba4 100644 --- a/site/pages/ja/privacy.html +++ b/site/pages/ja/privacy.html @@ -54,7 +54,7 @@

アプリが行うネットワークリクエスト

- + diff --git a/site/pages/ko/docs.html b/site/pages/ko/docs.html index 78e4f9bb..f42e5244 100644 --- a/site/pages/ko/docs.html +++ b/site/pages/ko/docs.html @@ -30,7 +30,7 @@

Flowlight 사용하기

@@ -100,10 +100,10 @@

여러 뷰

- - + +
リクエスト送信先含まれるものタイミング
アップデートの確認api.github.com最新リリースを尋ねる通常の HTTPS リクエスト。GitHub が見るのは、IP アドレスと Flowlight/<version> というユーザーエージェントです起動時と 1 日 1 回(0.1.2 以降)。オフのスイッチは設定 › 一般
アップデートのダウンロードgithub.comディスクイメージと、そのチェックサムファイル「ダウンロードして開く」をクリックしたときだけ
公式ルールパックのアップデートflowlight.xinbetween.com固定された公式フィードへの標準 HTTPS リクエストです。サーバには、IP アドレス、Flowlight/<version>ユーザーエージェント、条件付きキャッシュバリデータが表示されます。トラフィック、パック設定、デバイス識別子は送信されません起動後、確認期限に達している場合に実行し、その後は正常稼働中に1日最大1回実行します。デフォルトでオンです。ルール › パックでオフにするか、今すぐ確認を手動で実行できます
公式ルールパックのアップデートflowlight.xinbetween.com固定された公式フィードへの標準 HTTPS リクエストです。サーバには、IP アドレス、Flowlight/<version>ユーザーエージェント、条件付きキャッシュバリデータが表示されます。トラフィック、パック設定、デバイス識別子は送信されません起動後、確認期限に達している場合に実行し、その後は正常稼働中に1日最大1回実行します。デフォルトでオンです。ポリシー › 管理対象パックでオフにするか、「今すぐ確認」を手動で実行できます
逆引き DNSあなたが設定した DNS リゾルバホスト名が見つからなかった場合の、IP アドレスの登録名の照会必要に応じて、サンプラーのキャプチャモードで。ネットワーク機能拡張が動き出すまではこれがデフォルトです。オフのスイッチはありません。これがないとサンプラーはほとんどの接続に名前を付けられないからです。ネットワーク機能拡張はトラフィック自身から名前を付けるので、これを行いません
ネットワーク所有者の照会Team Cymru(DNS 経由)ホスト名がわからないパブリック IP アドレス。プライベートアドレスと予約アドレスは、この Mac から決して出ませんデフォルトでオン。オフのスイッチはキャプチャ › ネットワーク所有者を特定
ホスト型モデルでの質問あなたが選んだプロバイダ:Anthropic、OpenAI、Google Gemini、または互換のエンドポイントあなたの質問と、モデルが選んだ読み取り専用の Flowlight の問い合わせが返した結果。トラフィックのデータベース自体はアップロードされませんホスト型のプロバイダを選び、質問を送信したときだけ。質問の画面には、送信されたリクエストが表示されます
알림⌘5이상해 보인 모든 것. 앱과 목적지, 그리고 규칙의 기준을 넘긴 수치와 함께.
질문⌘6내 트래픽에 대한 평범한 말로 된 질문. 기록된 이력에서 로컬 모델이나 호스팅 모델이 답합니다.
검사⌘7 · Developer ID선택 사항. Flowlight를 거치도록 지정한 에이전트의 복호화된 요청과 그 뒤에 있는 도구 호출. 검색은 요청·응답 본문까지 들어갑니다.
규칙⌘8 · Developer ID
⌘7 · App Store
설정해 둔 모든 허용 목록과 거부, 각각이 무엇을 포괄하는지, 그리고 실제로 무엇을 막았는지.
소스⌘9 · Developer ID
⌘8 · App Store
컬렉터 상태, 기기 채널, 이름 지정 포착 범위를 세 개의 탭에서 표시합니다.
정책⌘8 · Developer ID
⌘7 · App Store
설정된 모든 정책과 실제 적용 상태, 그리고 하나로 모은 읽기 전용 활동 기록.
소스⌘9 · Developer ID
⌘8 · App Store
컬렉터 상태와 복구, 선택형 기기 채널, 포착 범위를 하나의 스크롤 가능한 운영 페이지에서 봅니다.
-

단축키는 보이는 사이드바 순서를 따릅니다. Developer ID 빌드에는 ⌘7의 검사가 있고, App Store 빌드에는 없으므로 규칙과 소스가 ⌘7과 ⌘8로 이동합니다.

+

단축키는 보이는 사이드바 순서를 따릅니다. Developer ID 빌드에는 ⌘7의 검사가 있고, App Store 빌드에는 없으므로 정책과 소스가 ⌘7과 ⌘8로 이동합니다.

실시간 뷰: 앱 목록에 각각의 현재 보내기·받기 속도와 가장 바쁜 목적지가 함께 나열되어 있습니다.
실시간, Flowlight가 처음 여는 화면.
@@ -144,7 +144,7 @@

도구와 MCP 서버

것은 둘 중 하나보다 더 많은 것을 말해 줍니다. 설정만 되어 있는 도메인은 한 번도 닿은 적이 없고, 관찰만 된 도메인은 미리 읽어 볼 수 있는 어디에도 적혀 있지 않았습니다.

도메인마다 거부하는 방법이 두 가지입니다. 이 에이전트에 대해는 에이전트와 그것이 시작하는 도구를 덮고, - 어디서나는 이 Mac의 모든 앱을 덮습니다. 둘 다 평범한 규칙을 씁니다. 그래서 위반 기록이 그것을 + 어디서나는 이 Mac의 모든 앱을 덮습니다. 둘 다 평범한 규칙을 씁니다. 그래서 활동이 그것을 보고하고 규칙 편집기가 그것을 설명하며, 평범한 규칙이기 때문에 편집기의 시뮬레이션이 규칙을 켜기 전에 그것이 무엇을 거부했을지 알려 줍니다.

버튼이 붙지 않는 도메인이 한 종류 있습니다. 모델 제공업체가 스스로 닿는 커넥터입니다(요청 본문에 선언되고 제공업체 쪽에서 @@ -333,15 +333,19 @@

모의 응답

그리고 목적지에는 여전히 연결합니다. 모의 응답은 답을 바꾸는 것이고 연결 자체가 되는지를 바꾸는 것이 아니므로, “API가 500을 돌려준다”는 만들 수 있지만 “API에 아예 닿지 않는다”는 만들 수 없습니다.

-

규칙

-

허용 목록은 에이전트 하나가 어디까지 갈 수 있는지에 대한 이야기입니다. 규칙은 그것의 일반형입니다. +

정책

+

정책은 Flowlight가 적용할 수 있는 모든 제어를 한데 모은 목록입니다. 일반 허용·차단 규칙, 에이전트 허용 목록, + 관리형 팩, 그리고 HTTPS 검사를 사용할 수 있을 때는 가드레일, 모의 응답, 요청 다시 쓰기, 응답 변환까지 포함합니다. 각 행은 설정된 상태와 + 실제 효력을 구분해 적용 중, 보고만 함, 감시 중, 대기 중, 예약됨, 일시 중지, 사용 완료, 적용 중, 검토 필요, 사용 불가, 호환되지 않음, + 실패를 표시합니다. 행을 선택하면 그 정책을 관리하는 원래 편집기가 열립니다. 정책 화면이 서로 다른 동의 방식과 저장 방식을 대신하거나 + 뒤섞지는 않습니다.

+

허용 목록은 에이전트 하나가 어디까지 갈 수 있는지에 대한 이야기입니다. 일반 규칙은 그것의 일반형입니다. 대상, 즉 앱이나 에이전트, 목적지, URL, 또는 그 둘의 조합을 지목하고 차단인지 허용인지 정합니다. 실시간이나 기록, AI 에이전트, 검사에서 아무 행이나 오른쪽 클릭하면 지금 보고 있는 것에 - 대한 규칙을 쓸 수 있습니다. 어느 경로로 만들어도 모두 규칙 화면의 같은 편집기로 모입니다. 보이지 않는 곳에서 - 효력을 갖는 규칙은 다시는 찾을 수 없는 규칙이 되기 때문입니다.

+ 대한 규칙을 쓸 수 있습니다. 어느 경로에서 만들었든 정책 화면에서 찾을 수 있고, 선택하면 그 규칙을 관리하는 원래 편집기가 열립니다.

템플릿. 스물두 개가 다섯 묶음으로 있습니다. 에이전트가 패키지 레지스트리나 다른 모델 제공업체에 닿지 못하게 막는 것, 데이터가 나가는 흔한 경로를 닫는 것(붙여넣기 사이트, 웹훅 수집기, 익명 파일 업로드, 터널, DNS over HTTPS), 제품 분석과 - 크래시 보고, 업무 시간 정책, 그리고 “이게 뭔지 알아내는 동안 한 시간만 막아 두기” 같은 일회성 규칙입니다. 규칙 툴바에서 + 크래시 보고, 업무 시간 정책, 그리고 “이게 뭔지 알아내는 동안 한 시간만 막아 두기” 같은 일회성 규칙입니다. 정책 툴바에서 둘러보거나, 실시간·기록·AI 에이전트·검사의 아무 행에서 오른쪽 클릭해 지금 보고 있는 것에 적용하세요.

각 템플릿은 무엇을 할지 먼저 알려줍니다. 규칙을 두 개 이상 쓰는 템플릿은 모든 호스트와 일정을 나열한 카드를 열고, 영구적인 것은 미리 채워진 편집기를 열며, 스스로 사라지는 규칙, 즉 한 시간짜리나 이번 Flowlight 실행 동안만 유효한 @@ -363,8 +367,9 @@

규칙

경로는 모르고, 확장 모드에서만 동작합니다. HTTPS 검사는 요청 하나를 거부하므로 URL까지 일치시킬 수 있고, 이유도 없이 죽은 소켓 대신 에이전트가 읽을 수 있는 상태 코드로 답할 수 있습니다. nettop 샘플러는 아무것도 거부하지 못합니다. 현재 설정으로 실행할 수 없는 규칙은 적용된 척 앉아 있지 않고 목록에서 그렇다고 밝힙니다.

-

두 번째 탭은 위반 기록입니다. 규칙이 판정한 모든 연결이, 앱과 향하던 곳, 어떤 규칙이 판정했는지, 그리고 - 그것이 거부였는지 통과시킨 예외였는지와 함께 나옵니다.

+

두 번째 뷰는 읽기 전용 활동입니다. 일반 규칙 판정, 허용 목록 위반, 관리형 팩 이벤트, 검사 정책 고유의 효과를 + 하나의 시간순 기록으로 모읍니다. 기존의 원본 기록을 그대로 연결해 보여 줄 뿐, 기록을 하나의 일반 이벤트 형식으로 다시 쓰지 않습니다. + App Store 빌드에서는 검사에 의존하는 정책 유형과 그 정책에 보관된 활동을 표시하지 않습니다.

이 규칙이 했을 일. 차단 규칙은 쓰기는 쉽고 켜기는 무섭습니다. 어떤 규칙에 대해 보통 가장 먼저 알게 되는 것은 그것이 무엇을 망가뜨렸는지이고, 그것도 나중에 다른 어딘가에서 무언가가 실패할 때 알게 되기 때문입니다. 규칙 편집기는 그 질문에 @@ -382,7 +387,7 @@

규칙

있었던 가장 오래된 트래픽도 함께 밝힙니다. 두 시간짜리 이력으로 대조한 규칙은 두 시간짜리 이력으로 대조한 것입니다.

관리형 광고 및 추적기 팩

-

규칙 › 팩에는 광고, 사이트 간 추적, 개인정보 보호 및 원격 측정이라는 세 가지 공식 호스트 이름 목록이 있습니다. 처음부터 표시되며 모두 기본적으로 꺼져 있습니다. 차단하려면 네트워크 확장 프로그램이 필요하며, nettop 샘플러는 관찰만 할 수 있습니다.

+

정책 › 관리형 팩에는 광고, 사이트 간 추적, 개인정보 보호 및 원격 측정이라는 세 가지 공식 호스트 이름 목록이 있습니다. 처음부터 표시되며 모두 기본적으로 꺼져 있습니다. 차단하려면 네트워크 확장 프로그램이 필요하며, nettop 샘플러는 관찰만 할 수 있습니다.

공식 서명 업데이트만 허용됩니다. Flowlight는 게시자가 서명한 최신 카탈로그가 있는지 하나의 고정 HTTPS 소스를 확인합니다. 자동 확인은 기본적으로 켜져 있으며 여기에서 끌 수 있습니다. 서명은 콘텐츠를 인증하지만 활성화하지는 않습니다. 새 팩은 꺼진 상태로 유지되며, 대상이 변경되면 활성화된 팩은 정확한 새 버전을 검토하고 저장을 누를 때까지 적용되지 않습니다. 서비스 중단 중에도 승인된 콘텐츠가 눈에 띄게 오래된 것으로 표시된 마지막 정상 버전으로 유지될 수 있습니다. 이는 호스트 이름 수준의 네트워크 차단이므로 DOM이나 레이아웃의 빈 공간, 브라우저 오버레이, 스크립트로 생성된 팝업 또는 네이티브 앱 창은 제거할 수 없습니다.

HTTPS 검사가 앱을 Flowlight의 프록시로 보내면, 일반 대상 규칙과 활성화된 관리형 팩은 원본 서버 연결이 열리기 전에 원래 앱의 신원을 사용해 결정됩니다. 인증서가 고정되었거나 복호화되지 않는 CONNECT 터널도 호스트 이름으로 판정할 수 있습니다. 에이전트 허용 목록은 계속 네트워크 확장을 직접 지나는 경로에만 적용됩니다. 소유자나 호스트 이름을 알 수 없으면 연결을 허용합니다.

@@ -431,7 +436,9 @@

에이전트 가드레일: 도구나 서버, 리소스를

이것은 샌드박스가 아닙니다. 거부된 도구는 더 이상 제공되지도, 응답되지도 않습니다. 하지만 셸이 남아 있는 에이전트는 그 도구가 하던 일을 직접 할 수 있습니다. Flowlight는 그것을 기록합니다. 막아 주는 척하지는 않습니다.

-

소스: 컬렉터와 엔진

+

소스: 컬렉터, 기기 및 포착 범위

+

소스는 컬렉터, 기기, 포착 범위를 차례로 뚜렷한 제목 아래 배치한 하나의 스크롤 가능한 운영 페이지입니다. + 상태 알림, 질문 답변, 복원된 창, 스크린샷 링크는 다른 섹션을 숨기지 않고 관련 섹션으로 스크롤한 뒤 잠시 강조합니다.

Flowlight는 소스 › 컬렉터에서 고르는 두 가지 방식 중 하나로 트래픽을 기록합니다. 두 방식은 무엇을 놓칠 수 있는지가 다르므로, 어느 쪽에 의지하기 전에 알아 둘 만합니다.

@@ -624,7 +631,7 @@

질문과 답

창을 닫아 두면 Flowlight는 거의 아무 일도 하지 않습니다. 메뉴 막대의 속도를 유지하고 1초에 한 행을 씁니다. 네트워크 확장은 1초마다 짧게 사는 nettop 프로세스를 실행하는 대체 샘플러보다 비용이 적습니다.
연결을 차단할 수 있나요?
-
네, 네트워크 확장이 있으면 됩니다. 에이전트의 허용 목록은 경고뿐 아니라 거부도 할 수 있고, 규칙은 앱이나 +
네, 네트워크 확장이 있으면 됩니다. 에이전트의 허용 목록은 경고뿐 아니라 거부도 할 수 있고, 규칙은 앱이나 목적지, URL을 원하는 기간만큼 막을 수 있으며, 가드레일은 모델에 제공되기 전에 에이전트에서 도구를 걷어낼 수 있습니다. 요청하기 전까지 차단되는 것은 없고, 모든 거부에는 빠져나갈 길이 함께 있습니다.
네트워크 확장이 꼭 필요한가요?
diff --git a/site/pages/ko/index.html b/site/pages/ko/index.html index 671af742..228d897e 100644 --- a/site/pages/ko/index.html +++ b/site/pages/ko/index.html @@ -110,11 +110,11 @@

기록

다 어디로 갔나? 초에서 연까지, 앱·목적지·IP

알림

무엇이 이상했나? 목적지와 임계값까지 밝히는, 설명되는 앱별 알림.

⌘5
-

규칙

무엇을 거부했고, 실제로 막히고 있나? 모든 규칙과 각 규칙이 막은 것, 그리고 모든 판정의 기록.

⌘8 · Developer ID / ⌘7 · App Store
+

정책

어떤 제어가 있고 실제로 효력이 있나? 모든 정책과 실제 상태, 그리고 하나로 모은 활동 기록.

⌘8 · Developer ID / ⌘7 · App Store

질문

평소 쓰는 말로 묻고 기록에서 답을 받습니다. 실행된 질의는 아래에 전부 표시됩니다.

⌘6
-

소스

데이터가 들어오는지, 이름이 붙은 비율과 활성 기기 채널은 어떤지. 컬렉터, 기기, 포착 범위를 한곳에서 봅니다.

⌘9 · Developer ID / ⌘8 · App Store
+

소스

데이터가 들어오는지, 어떤 기기 채널이 활성화되었는지, Flowlight가 어디까지 파악할 수 있는지. 컬렉터부터 포착 범위까지 한 페이지에서 봅니다.

⌘9 · Developer ID / ⌘8 · App Store

검사

선택 사항. 에이전트가 실제로 무엇을 보냈고, 어떤 도구 호출이 그렇게 했나? 복호화된 요청, 도구 호출, 결과.

⌘7 · Developer ID
diff --git a/site/pages/ko/privacy.html b/site/pages/ko/privacy.html index 379a8988..2a92755d 100644 --- a/site/pages/ko/privacy.html +++ b/site/pages/ko/privacy.html @@ -57,7 +57,7 @@

앱이 내는 네트워크 요청

- + diff --git a/site/pages/plugins.html b/site/pages/plugins.html index 3a03756e..e314ef89 100644 --- a/site/pages/plugins.html +++ b/site/pages/plugins.html @@ -65,6 +65,7 @@

Provenance and package review

Findings

A JavaScript plugin defines a synchronous evaluate(context) function. Flowlight passes context.manifest and a sanitized context.exchange containing only the metadata families declared in permissions. Scripts run out of process with an empty environment, a 128 KB script limit and a short timeout; failures produce no findings.

+

context.manifest.configuration contains that plugin's local string settings. context.manifest.providerProfiles is a separate array of enabled local profiles assigned to the plugin with the same guardrail provider; each entry contains only id, provider, optional endpoint, and non-secret configuration. Flowlight never gives a plugin credential values or Keychain references, and a profile does not initiate a provider request.

A finding is a bounded record attached to one inspected exchange. Evidence should name facts, not leak content: method, host, redacted path shape, byte counts, header names, model provider, tool names, MCP server names, approval mode and policy labels. Do not store header values, raw bodies, credentials, full tool inputs, prompts, completions or unredacted query strings.

When a manifest declares the suggestAction capability and permission, a finding can also include suggestedActions. Flowlight stores those as inert suggestions; Inspect opens the matching editor and saves only when the user reviews and clicks Save.

{
@@ -113,7 +114,7 @@ 

Supported guardrail providers

요청보내는 곳담기는 내용시점
업데이트 확인api.github.com최신 릴리스를 묻는 일반적인 HTTPS 요청. GitHub는 IP 주소와 Flowlight/<version> 사용자 에이전트를 보게 됩니다0.1.2부터, 실행 시와 하루에 한 번. 끄는 스위치는 설정 › 일반
업데이트 다운로드github.com디스크 이미지와 체크섬 파일다운로드 후 열기를 클릭할 때만
공식 규칙 팩 업데이트flowlight.xinbetween.com고정된 공식 피드로 보내는 표준 HTTPS 요청입니다. 서버에는 사용자의 IP 주소, Flowlight/<version> 사용자 에이전트 및 조건부 캐시 유효성 검사기가 표시되며, 트래픽, 팩 설정 또는 기기 식별자는 전송되지 않습니다시작 후 확인 시점이 되면 실행되고, 이후 정상 작동 중에는 하루에 최대 한 번 실행됩니다. 기본적으로 켜져 있으며, 규칙 › 팩에서 끄거나 지금 확인을 수동으로 실행할 수 있습니다
공식 규칙 팩 업데이트flowlight.xinbetween.com고정된 공식 피드로 보내는 표준 HTTPS 요청입니다. 서버에는 사용자의 IP 주소, Flowlight/<version> 사용자 에이전트 및 조건부 캐시 유효성 검사기가 표시되며, 트래픽, 팩 설정 또는 기기 식별자는 전송되지 않습니다시작 후 확인 시점이 되면 실행되고, 이후 정상 작동 중에는 하루에 최대 한 번 실행됩니다. 기본적으로 켜져 있으며, 정책 › 관리형 팩에서 끄거나 지금 확인을 수동으로 실행할 수 있습니다
역방향 DNS설정된 DNS 리졸버호스트 이름이 관측되지 않았을 때, IP 주소에 등록된 이름 조회필요할 때, 샘플러 캡처 모드에서. 네트워크 확장이 돌기 시작할 때까지는 이것이 기본값입니다. 끄는 스위치는 없습니다. 이것이 없으면 샘플러는 대부분의 연결에 이름을 붙일 수 없습니다. 네트워크 확장은 트래픽 자체에서 이름을 붙이므로 이 조회를 하지 않습니다
네트워크 소유자 조회Team Cymru, DNS로알려진 호스트 이름이 없는 공용 IP 주소. 사설 주소와 예약 주소는 절대 Mac을 벗어나지 않습니다기본값 켬. 끄는 스위치는 캡처 › 네트워크 소유자 확인
호스팅 모델로 질문선택한 제공업체: Anthropic, OpenAI, Google Gemini 또는 호환 엔드포인트내 질문과, 모델이 고른 Flowlight 읽기 전용 질의가 돌려준 결과. 트래픽 데이터베이스 자체는 업로드되지 않습니다호스팅 제공업체를 고르고 질문을 보낼 때만. 질문 화면에 전송된 요청이 표시됩니다

Template package

-

The starter package lives at templates/inspection-plugin. It includes a manifest, plugin.js, sample findings and a validator for ordinary traffic and LLM/MCP guardrail plugins. Import the manifest JSON from Settings › Plugins and Flowlight will show the review sheet before installation.

+

The starter package lives at templates/inspection-plugin. It includes a manifest, plugin.js, sample findings and a validator for ordinary traffic and LLM/MCP guardrail plugins. In the Developer edition, open Inspect › Plugins & Providers and import the manifest JSON; Flowlight shows the review sheet before installation.

cp -R templates/inspection-plugin my-flowlight-plugin
 cd my-flowlight-plugin
 node validate.js
diff --git a/site/pages/privacy.html b/site/pages/privacy.html index e123ca8d..9f8dc9ac 100644 --- a/site/pages/privacy.html +++ b/site/pages/privacy.html @@ -63,7 +63,7 @@

Network requests the app makes

RequestSent toWhat it containsWhen Update checkapi.github.comA standard HTTPS request for the latest release; GitHub sees your IP address and the Flowlight/<version> user agentAfter a clean quit and relaunch, and daily. Off switch in Settings › General Update downloadgithub.comThe disk image and its checksum fileOnly when you click Download & Open - Official rule-pack updateflowlight.xinbetween.comA standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/<version> user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifierAfter startup when due and then at most daily while healthy. On by default; off switch and manual Check Now in Rules › Packs + Official rule-pack updateflowlight.xinbetween.comA standard HTTPS request to the fixed official feed for a publisher-signed catalog. The server sees your IP address, the Flowlight/<version> user agent, and conditional cache validators; it receives no traffic, pack settings, or device identifierAfter startup when due and then at most daily while healthy. On by default; off switch and manual Check Now under Policies › Managed packs Reverse DNSYour configured DNS resolverA lookup of an IP address's registered name, when no hostname was seenAs needed, in the sampler capture mode, which is the default until the Network Extension is running. No off switch: without it the sampler cannot name most connections. The Network Extension names them from the traffic itself and does not do this Network owner lookupTeam Cymru, over DNSPublic IP addresses with no known hostname. Private and reserved addresses never leave your MacOn by default. Off switch in Sources › Collector › Identify network owners Ask with a hosted modelThe provider you select: Anthropic, OpenAI, Google Gemini or a compatible endpointYour question and the results returned by the read-only Flowlight queries selected by the model. The traffic database itself is not uploadedOnly when you select a hosted provider and submit a question. The Ask screen shows the transmitted requests diff --git a/site/pages/pt-PT/docs.html b/site/pages/pt-PT/docs.html index ef6724b9..c0844cbe 100644 --- a/site/pages/pt-PT/docs.html +++ b/site/pages/pt-PT/docs.html @@ -30,7 +30,7 @@

Usar o Flowlight

@@ -101,10 +101,10 @@

As vistas

Alertas⌘5Tudo o que pareceu invulgar, com a app, o destino e o número que disparou a regra. Perguntar⌘6Perguntas sobre o seu próprio tráfego em linguagem simples, respondidas a partir do histórico registado por um modelo local ou alojado. Inspecionar⌘7 · Developer IDOpcional: pedidos desencriptados dos agentes que encaminha pelo Flowlight e as chamadas de ferramentas por trás deles. A pesquisa entra nos corpos dos pedidos e das respostas. - Regras⌘8 · Developer ID
⌘7 · App StoreTodas as listas de permissões e recusas que definiu, o que cada uma abrange e o que de facto travou. - Origens⌘9 · Developer ID
⌘8 · App StoreEstado do coletor, canais de dispositivos e cobertura de nomes em três separadores. + Políticas⌘8 · Developer ID
⌘7 · App StoreTodas as políticas configuradas, se estão realmente em vigor e um histórico unificado de atividade só de leitura. + Origens⌘9 · Developer ID
⌘8 · App StoreEstado e recuperação do Coletor, canais opcionais de dispositivos e cobertura numa única página operacional deslocável. -

Os atalhos seguem a ordem visível da barra lateral. As versões Developer ID incluem Inspecionar em ⌘7; as versões App Store omitem-no, por isso Regras e Origens passam para ⌘7 e ⌘8.

+

Os atalhos seguem a ordem visível da barra lateral. As versões Developer ID incluem Inspecionar em ⌘7; as versões App Store omitem-no, por isso Políticas e Origens passam para ⌘7 e ⌘8.

A vista Em direto: uma lista de aplicações com as suas taxas de envio e receção atuais e o destino mais ativo.
Em direto, a vista com que o Flowlight abre.
@@ -337,16 +337,20 @@

Respostas simuladas

continua a ser feita: uma simulação muda a resposta, não a possibilidade de estabelecer a ligação, por isso «a API devolve 500» funciona e «a API está completamente inacessível» não.

-

Regras

-

Uma lista de permissões é a ideia de um agente sobre onde pode ir. Uma regra é a versão geral: nomeia um +

Políticas

+

Políticas reúne num só inventário todos os controlos que o Flowlight pode aplicar: regras comuns de permissão ou bloqueio, listas de + permissões dos agentes, pacotes geridos e, quando a inspeção HTTPS está disponível, proteções, simulações, reescritas de pedidos e transformações de + respostas. Cada linha distingue o estado configurado do efeito real: em aplicação, apenas a comunicar, a vigiar, à espera, agendada, em pausa, + esgotada, a ser aplicada, a precisar de revisão, indisponível, incompatível ou com falha. Selecionar uma linha abre o editor contextual responsável + por essa política; Políticas não substitui nem confunde os diferentes modelos de consentimento e persistência.

+

Uma lista de permissões é a ideia de um agente sobre onde pode ir. Uma regra comum é a versão geral: nomeia um sujeito — uma app, um agente, um destino, um URL, ou um par formado por dois deles — e diz bloquear ou permitir. Clique com o botão direito em qualquer linha de Em direto, Histórico, Agentes de IA ou Inspecionar para escrever uma sobre o que está a ver; - todas elas vão dar ao mesmo editor no ecrã Regras, porque uma regra que entrasse em vigor fora da vista - seria uma regra que nunca mais conseguiria encontrar.

+ cada uma continua disponível em Políticas e abre no seu editor responsável.

Modelos. Vinte e dois, em cinco grupos — impedir um agente de chegar a registos de pacotes ou a outros fornecedores de modelos, fechar as vias habituais por onde os dados saem (sites de paste, apanhadores de webhooks, depósitos anónimos de ficheiros, túneis, DNS over HTTPS), analítica de produto e relatórios de falhas, políticas de horário de trabalho, e o pontual «bloquear isto por uma hora enquanto descubro - o que é». Consulte-os na barra de ferramentas de Regras, ou clique com o botão direito numa linha em Em direto, Histórico, Agentes de IA ou Inspecionar e + o que é». Consulte-os na barra de ferramentas de Políticas, ou clique com o botão direito numa linha em Em direto, Histórico, Agentes de IA ou Inspecionar e aplique um ao que está a ver.

Cada modelo diz o que vai fazer antes de o fazer. Tudo o que escreva mais do que uma regra abre um cartão com todos os anfitriões e horários; tudo o que seja permanente abre o editor pré-preenchido; e só as regras que se desfazem sozinhas — uma @@ -367,8 +371,9 @@

Regras

endereço e a porta, nada sobre caminhos, e só no modo de extensão. A inspeção HTTPS recusa um pedido, por isso consegue corresponder a um URL e responder com um estado que o agente pode ler, em vez de um socket que morreu sem dizer porquê. O amostrador nettop não consegue recusar nada. Uma regra que a configuração atual não consegue aplicar di-lo na lista, em vez de ficar ali com ar de estar em vigor.

-

O segundo separador é o fluxo de violações: todas as ligações que uma regra decidiu, com a app, para onde ia, que - regra a decidiu e se isso foi uma recusa ou uma exceção a deixá-la passar.

+

A segunda vista é a Atividade, só de leitura: reúne numa única cronologia as decisões das regras comuns, as violações das listas de permissões, + os eventos dos pacotes geridos e os efeitos distintos das políticas de inspeção. Baseia-se nos registos originais fidedignos em vez de reescrever o + histórico num único modelo genérico de eventos; as versões Store omitem os tipos de política dependentes da inspeção e a respetiva atividade conservada.

O que teria feito. Uma regra de bloqueio é fácil de escrever e assustadora de ligar, porque a primeira coisa que normalmente se aprende sobre uma delas é o que estragou — e aprende-se mais tarde, a partir de algo que falha noutro sítio. @@ -389,7 +394,7 @@

Regras

testada contra duas horas de histórico.

Pacotes geridos de publicidade e rastreadores

-

Regras › Pacotes contém três listas oficiais de nomes de anfitrião: Publicidade, Rastreio entre sites e Privacidade e telemetria. Estão visíveis desde o início e todas desativadas por predefinição. O bloqueio requer a Extensão de rede; o amostrador nettop apenas pode observar.

+

Políticas › Pacotes geridos contém três listas oficiais de nomes de anfitrião: Publicidade, Rastreio entre sites e Privacidade e telemetria. Estão visíveis desde o início e todas desativadas por predefinição. O bloqueio requer a Extensão de rede; o amostrador nettop apenas pode observar.

Apenas atualizações oficiais e assinadas. O Flowlight consulta uma única fonte HTTPS fixa para procurar catálogos mais recentes assinados pelo editor. As verificações automáticas estão ativas por predefinição e podem ser desativadas aqui. Uma assinatura autentica o conteúdo, mas não o ativa: um novo pacote permanece desativado e, se os destinos forem alterados, um pacote ativo deixa de ser aplicado até que reveja a nova versão exata e prima Guardar. Durante uma interrupção, o conteúdo aceite pode permanecer disponível como a última versão válida conhecida, visivelmente assinalada como desatualizada. Estes são bloqueios de rede ao nível do nome de anfitrião: não podem remover espaços vazios no DOM ou no esquema, sobreposições do navegador, janelas pop-up geradas por scripts ou janelas de aplicações nativas.

Quando a inspeção HTTPS encaminha uma app pelo proxy do Flowlight, este decide as regras de destino comuns e os pacotes geridos ativos com base na identidade original da app antes de abrir qualquer ligação ao servidor de origem. Nos túneis CONNECT com certificado fixado ou que nunca são decifrados, isto funciona pelo nome de anfitrião; as listas de permissões dos agentes continuam limitadas ao caminho direto pela Extensão de rede. Se o proprietário ou o nome de anfitrião for desconhecido, a ligação é permitida.

@@ -438,7 +443,10 @@

Proteções dos agentes: bloquear uma ferramenta, um servido

Isto não é uma sandbox. Uma ferramenta recusada deixa de ser oferecida e deixa de ser respondida. Um agente que ainda tenha uma shell pode fazer à mão o que a ferramenta teria feito. O Flowlight vai registá-lo; não finge impedi-lo.

-

Origens: coletor e motores

+

Origens: coletor, dispositivos e cobertura

+

Origens é uma única página operacional deslocável: primeiro o Coletor, depois Dispositivos e Cobertura como secções claramente + identificadas. Os avisos de estado, as respostas de Perguntar, as janelas restauradas e as ligações usadas nas capturas de ecrã deslocam-se até à secção relevante + e realçam-na por instantes sem ocultar as restantes.

O Flowlight regista o tráfego de uma de duas formas, escolhida em Origens › Coletor. Diferem no que podem deixar escapar, e vale a pena entender isso antes de confiar numa delas.

@@ -630,7 +638,7 @@

Perguntas e respostas

Com a janela fechada, o Flowlight quase não faz nada: mantém as taxas na barra de menus e escreve uma linha por segundo. A extensão de rede custa menos do que o amostrador de reserva, que inicia um processo nettop de vida curta a cada segundo.
Consegue bloquear ligações?
-
Sim, com a extensão de rede. A lista de permissões de um agente pode recusar e não só avisar, uma regra pode bloquear uma app, +
Sim, com a extensão de rede. A lista de permissões de um agente pode recusar e não só avisar, uma regra pode bloquear uma app, um destino ou um URL pelo tempo que quiser, e uma proteção pode retirar uma ferramenta a um agente antes de ser oferecida ao seu modelo. Nada bloqueia até o pedir, e cada recusa traz consigo a sua saída.
Preciso da extensão de rede?
diff --git a/site/pages/pt-PT/index.html b/site/pages/pt-PT/index.html index 1cdc1401..44dd8551 100644 --- a/site/pages/pt-PT/index.html +++ b/site/pages/pt-PT/index.html @@ -110,11 +110,11 @@

Histórico

Para onde foi tudo? Do segundo ao ano, agrupado por app, d

Alertas

O que pareceu errado? Alertas por app, explicáveis, com o destino e o limiar.

⌘5
-

Regras

O que recusei e está a acontecer? Todas as regras, o que cada uma bloqueou e um fluxo de todas as decisões.

⌘8 · Developer ID / ⌘7 · App Store
+

Políticas

Que controlos existem e estão em vigor? Todas as políticas, o seu estado real e um histórico unificado de atividade.

⌘8 · Developer ID / ⌘7 · App Store

Perguntar

Perguntas em linguagem comum, respondidas a partir do histórico — com todas as consultas que fez listadas abaixo.

⌘6
-

Origens

Os dados estão a chegar, quanto está identificado e que canais de dispositivos estão ativos? Coletor, Dispositivos e Cobertura num só lugar.

⌘9 · Developer ID / ⌘8 · App Store
+

Origens

Os dados estão a chegar, que canais de dispositivos estão ativos e o que consegue o Flowlight contabilizar? Uma única página, do Coletor à Cobertura.

⌘9 · Developer ID / ⌘8 · App Store

Inspecionar

Opcional. O que é que o agente enviou de facto, e que chamada de ferramenta o fez? Pedidos desencriptados, chamadas de ferramentas, resultados.

⌘7 · Developer ID
diff --git a/site/pages/pt-PT/privacy.html b/site/pages/pt-PT/privacy.html index 5395ff78..e6434fe4 100644 --- a/site/pages/pt-PT/privacy.html +++ b/site/pages/pt-PT/privacy.html @@ -56,7 +56,7 @@

Pedidos de rede que a app faz

- + diff --git a/site/pages/releases.html b/site/pages/releases.html index 790e6aec..3d0c39d8 100644 --- a/site/pages/releases.html +++ b/site/pages/releases.html @@ -13,7 +13,23 @@

What's new

-

0.17.3

Latest
+

0.17.7

Latest
+
    +
  • Account for every AI agent. Agents now combines installed apps, configured workspaces, known launchers and observed traffic into one inventory, with explainable health reasons for evidence, routing, inspection, policy coverage and unresolved alerts instead of an opaque score.
  • +
  • Take alerts from new to resolved. Alerts now supports new, acknowledged, investigating, resolved and dismissed states; dispositions, notes and bulk actions stay separate from policy Allow actions, while the sidebar badge counts only new alerts.
  • +
  • Manage inspection extensions where they work. The Developer edition moves plugin packages into Inspect › Plugins & Providers and adds versioned local provider profiles with compatible plugin assignment, endpoint validation and Keychain references without persisting raw credentials.
  • +
  • Inspect Live traffic without relying on color or a pointer. The Live chart adds keyboard traversal, VoiceOver details, strict plot hit testing, a solid/circle download series and dashed/diamond upload series, with an executable light/dark palette gate.
  • +
  • Keep redesigned paths within measured limits. Fail-capable numeric budgets cover startup, a 600-edge graph, a 10,001-row History query, a 10,000-alert query, a 1,000-agent inventory and 30,000 built-in plugin evaluations, with complete diagnostics on failure.
  • +
  • Carry every completed change forward safely. The unpublished 0.17.4, 0.17.5 and 0.17.6 work ships here: Policies preserves each policy type's authoritative editor and evidence, Sources is one Collector–Devices–Coverage document, History clears pointer details without inventing a keyboard selection, and release review receipts remain valid when GitHub hides a trusted reviewer's private organization membership from the workflow token.
  • +
  • Reuse CI only when the evidence is exact. Pull-request app jobs may reuse a successful canonical run only when its full Git tree, app fingerprint, check conclusions, receipt schema and immutable artifact identity match; every invalid or forked case executes the current jobs.
  • +
  • Deploy only the site CI verified. Pages consumes the exact manifested site artifact from successful canonical main CI, with no source checkout or rebuild, while weekly full CodeQL remains in place.
  • +
  • Keep untrusted pull-request refs out of checkouts. Merge compatibility uses GitHub's trusted pull-request event merge commit rather than constructing a checkout ref from pull-request input, resolving code-scanning alert 4 without granting privileged workflow access.
  • +
  • Sign each release once. The immutable tag run builds, signs, notarizes, Gatekeeper-verifies, attests and retains one artifact; credential-free publication can only verify and publish those exact prepared bytes, while accepted Apple tickets survive a bounded multi-minute CloudKit delivery outage without resubmission.
  • +
+
+ +
+

0.17.3

  • Keep every input in one operational home. Capture, Devices and Coverage are now Collector, Devices and Coverage tabs inside Sources, with a shared health summary and a step-by-step recovery card when traffic is not arriving.
  • Tune alerts where their results appear. Detection controls now live beside the alert list under Alerts › Detection, while database retention, location and deletion live in Settings › Data & Privacy.
  • diff --git a/site/pages/zh-Hans/docs.html b/site/pages/zh-Hans/docs.html index 61c034dd..21f82c64 100644 --- a/site/pages/zh-Hans/docs.html +++ b/site/pages/zh-Hans/docs.html @@ -30,7 +30,7 @@

    使用 Flowlight

@@ -97,10 +97,10 @@

各个视图

- - + +
PedidoEnviado paraO que contémQuando
Procura de atualizaçõesapi.github.comUm pedido HTTPS normal pela versão mais recente; o GitHub vê o seu endereço IP e o agente de utilizador Flowlight/<version>Na abertura e uma vez por dia, a partir da 0.1.2. Interruptor para desativar em Definições › Geral
Transferência da atualizaçãogithub.comA imagem de disco e o seu ficheiro de soma de verificaçãoSó quando clica em Transferir e abrir
Atualização oficial do pacote de regrasflowlight.xinbetween.comUm pedido HTTPS padrão ao feed oficial fixo. O servidor vê o seu endereço IP, o agente do utilizador Flowlight/<version> e os validadores condicionais de cache; não recebe tráfego, definições dos pacotes nem qualquer identificador do dispositivoApós o arranque, quando for necessário, e depois, no máximo, uma vez por dia enquanto funcionar normalmente. Ativa por predefinição; opção para desativar e Procurar agora manualmente em Regras › Pacotes
Atualização oficial do pacote de regrasflowlight.xinbetween.comUm pedido HTTPS padrão ao feed oficial fixo. O servidor vê o seu endereço IP, o agente do utilizador Flowlight/<version> e os validadores condicionais de cache; não recebe tráfego, definições dos pacotes nem qualquer identificador do dispositivoApós o arranque, quando for necessário, e depois, no máximo, uma vez por dia enquanto funcionar normalmente. Ativa por predefinição; opção para desativar e Procurar agora manualmente em Políticas › Pacotes geridos
DNS inversoO resolvedor de DNS que tem configuradoUma consulta do nome registado de um endereço IP, quando não foi visto nenhum nome de anfitriãoConforme necessário, no modo de captura por amostrador, que é a predefinição até a extensão de rede estar a correr. Sem interruptor para desligar: sem isto, o amostrador não consegue dar nome à maioria das ligações. A extensão de rede dá-lhes nome a partir do próprio tráfego e não faz esta consulta
Consulta do proprietário da redeTeam Cymru, por DNSEndereços IP públicos sem nome de anfitrião conhecido. Os endereços privados e reservados nunca saem do seu MacAtivada por predefinição. Interruptor para desativar em Captura › Identificar os proprietários da rede
Perguntar com um modelo alojadoO fornecedor que escolher: Anthropic, OpenAI, Google Gemini ou um endpoint compatívelA sua pergunta e os resultados devolvidos pelas consultas de leitura do Flowlight escolhidas pelo modelo. A base de dados de tráfego em si não é enviadaSó quando escolhe um fornecedor alojado e submete uma pergunta. O ecrã Perguntar mostra os pedidos transmitidos
警报⌘5所有看起来不寻常的事,带上应用、目标和触发规则的那个数字。
提问⌘6用平常的话问你自己的流量,由本地模型或托管模型从记录下来的历史里作答。
检查⌘7 · Developer ID可选:你交给 Flowlight 转发的那些代理的解密请求,以及它们背后的工具调用。搜索能深入请求和响应正文。
规则⌘8 · Developer ID
⌘7 · App Store
你设过的每一条允许列表和拒绝,各自管到哪里,以及实际拦下了什么。
来源⌘9 · Developer ID
⌘8 · App Store
在三个标签页中显示收集器状态、设备通道和命名覆盖范围。
策略⌘8 · Developer ID
⌘7 · App Store
每项已配置的策略、是否真正生效,以及统一的只读活动记录。
来源⌘9 · Developer ID
⌘8 · App Store
收集器状态与恢复、可选设备通道和覆盖范围,集中在一张可滚动的运维页面中。
-

快捷键遵循可见的侧边栏顺序。Developer ID 版本在 ⌘7 包含检查;App Store 版本省略检查,因此规则和来源会移到 ⌘7 和 ⌘8。

+

快捷键遵循可见的侧边栏顺序。Developer ID 版本在 ⌘7 包含检查;App Store 版本省略检查,因此策略和来源会移到 ⌘7 和 ⌘8。

实时视图:一列应用,各自带着当前的发送与接收速率,以及最忙的目标。
实时,Flowlight 打开时进入的那个视图。
@@ -139,7 +139,7 @@

工具与 MCP 服务器

每个域名都标明是从这四种里的哪一种知道的。带着好几个标签的域名,是比只带一个标签更强的事实:配置文件里写了而且确实 连过,比其中任何一项单独说得更多;只被配置过的域名从未被访问过,而只被观察到的域名从来没有写在任何你本可以读到的地方。

每个域名给出两种拒绝:对这个代理,覆盖该代理以及它启动的工具;或者在所有地方,覆盖这台 Mac 上的 - 每一个应用。两者写下的都是普通规则,所以违规流水会报告它们,规则编辑器会解释它们——而且正因为它们是普通规则, + 每一个应用。两者写下的都是普通规则,所以活动会报告它们,规则编辑器会解释它们——而且正因为它们是普通规则, 编辑器里的模拟会在你把它打开之前告诉你,它本会拒绝掉什么。

有一类域名没有按钮。模型提供方自己去连的连接器——在请求正文里声明,从提供方那一侧连接——根本不会碰到这台 Mac,所以这里的 任何规则都够不到它。那一行会把这件事说出来,而不是递给你一个悄悄什么都不做的拒绝。如果同一个主机在本地也被配置过,或者 @@ -304,16 +304,16 @@

模拟响应

连接:模拟改变的是答案,而不是连接能否建立,所以“这个 API 返回 500”行得通,而“这个 API 完全不可达”不行。

-

规则

-

允许列表是某一个代理关于它可以去哪里的说法。规则是它的通用版本:它指明一个 +

策略

+

策略集中列出 Flowlight 能执行的所有管控:普通的允许或阻止规则、代理允许列表、托管包,以及在支持 HTTPS 检查时的护栏、模拟响应、请求改写和响应转换。每一行都会区分配置状态与实际效果:生效、仅报告、监视、等待、已排期、暂停、已用完、正在应用、需要检查、不可用、不兼容或失败。选择一行会打开负责该策略的对应编辑器;“策略”不会取代或混淆这些功能各自独立的授权和持久化方式。

+

允许列表是某一个代理关于它可以去哪里的说法。普通规则是它的通用版本:它指明一个 对象——应用、代理、目标、URL,或其中两者的组合——并决定阻止 还是允许。在实时、历史、AI 代理或检查里右键任意一行,就能针对你正看着的东西写一条; - 它们全都落在规则界面上的同一个编辑器里,因为一条在看不见的地方生效的规则, - 会是一条你再也找不到的规则。

+ 每条规则都能在“策略”中找到,并会在其权威编辑器中打开。

模板。共 22 个,分为五组——阻止代理访问软件包仓库或其他 模型提供方;封住数据外流的常见途径(粘贴站点、webhook 接收器、匿名文件投放、隧道、DNS over HTTPS);产品分析与崩溃上报;工作时段策略;以及一次性的“先把这个拦一小时,我弄清它 - 是什么”。可以从规则工具栏浏览它们,也可以在实时、历史、AI 代理或检查的任意一行上右键, + 是什么”。可以从策略工具栏浏览它们,也可以在实时、历史、AI 代理或检查的任意一行上右键, 把某个模板应用到你正看着的东西上。

每个模板在动手之前都会说明它要做什么。凡是会写入多条规则的,都会打开一张卡片,列出每个 主机和时段;凡是永久生效的,都会打开预填好的编辑器;而只有会自行撤销的规则——一 @@ -334,8 +334,7 @@

规则

地址和端口,对路径一无所知,而且只在扩展模式下有效。HTTPS 检查拒绝的是单个请求,所以它能匹配 URL,并用代理读得懂的状态码作答,而不是一个不声不响就断掉的套接字。nettop 采样器 完全无法拒绝任何东西。当前配置无法执行的规则会在列表里明说,而不是摆在那里装作已经生效。

-

第二个标签页是违规流水:每一条由规则判定的连接,带上应用、它要去哪里、是哪条 - 规则判定的,以及那究竟是一次拒绝,还是一次放它通过的例外。

+

第二个视图是只读的活动:普通规则判定、允许列表违规、托管包事件,以及各类检查策略的效果,都按时间排列在一处。它会适配现有的权威记录,而不是把历史改写成一种笼统的事件模型;App Store 版本会省略依赖检查的策略类型及其保留的活动。

它本来会做什么。阻止的规则写起来容易,打开却让人害怕,因为你通常最先了解到的是它 弄坏了什么——而且是后来才知道的,从别处某个东西失败上知道的。规则编辑器先回答这个问题:它拿记录 @@ -353,7 +352,7 @@

规则

的最早的流量:一条只对照了两小时历史的规则,就是只对照了两小时历史。

托管广告和跟踪器包

-

规则 › 包包含三个官方主机名列表:广告、跨站跟踪和隐私与遥测。它们从一开始就可见,并且默认均为关闭状态。阻止功能需要网络扩展;nettop 采样器只能观察。

+

策略 › 托管包包含三个官方主机名列表:广告、跨站跟踪和隐私与遥测。它们从一开始就可见,并且默认均为关闭状态。阻止功能需要网络扩展;nettop 采样器只能观察。

仅限官方签名更新。 Flowlight 会检查一个固定的 HTTPS 源,以查找由发布者签名的新版目录。自动检查默认开启,可在此处关闭。签名可验证内容,但不会启用内容:新包会保持关闭;如果目标发生变化,已启用的包将停止执行,直到你审查确切的新版本并按下保存。服务中断期间,已接受的内容可能会继续作为最后已知正常版本保留,并明确显示为已过期。这些是主机名级别的网络阻止,无法移除 DOM 或布局中的空白、浏览器叠加层、脚本生成的弹出窗口或原生应用窗口。

当 HTTPS 检查将应用经由 Flowlight 的代理转发时,普通目标规则和已启用的托管包会在打开任何源站连接之前,使用原应用的身份作出判定。对于固定了证书或始终不解密的 CONNECT 隧道,这一判定按主机名进行;代理允许列表仍只适用于直接经过网络扩展的路径。如果所有者或主机名未知,则会允许连接。

@@ -402,7 +401,8 @@

代理护栏:阻止一个工具、一个服务器或一个

这不是沙盒。被拒绝的工具不再提供给模型,也不再被响应。而仍然拥有 shell 的代理,可以手动做到那个工具本来要做的事。Flowlight 会把它记录下来;它不会假装能阻止。

-

来源:收集器与引擎

+

来源:收集器、设备与覆盖范围

+

来源是一张可滚动的运维页面:先是收集器,接着是标题清晰的设备与覆盖范围分区。状态通知、提问答案、恢复后的窗口和截图链接都会滚动到相关分区并短暂突出显示,同时不隐藏其他内容。

Flowlight 用两种方式之一记录流量,在来源 › 收集器里选择。它们会漏掉的东西不同,在依赖任何 一种之前,值得先了解清楚。

@@ -588,7 +588,7 @@

问题与解答

窗口关闭时 Flowlight 几乎什么都不做:它保留菜单栏速率,每秒写入一行。网络扩展 比作为后备的采样器更省,后者每秒都要启动一个短命的 nettop 进程。
它能阻止连接吗?
-
能,配合网络扩展。代理的允许列表除了警告也可以拒绝,一条规则可以按你指定的时长阻止一个应用、 +
能,配合网络扩展。代理的允许列表除了警告也可以拒绝,一条规则可以按你指定的时长阻止一个应用、 一个目标或一个 URL,而一条护栏可以在模型看到某个工具之前,把它从代理手里 拿走。在你要求之前不会阻止任何东西,而且每次拒绝都带着它的出口。
我需要网络扩展吗?
diff --git a/site/pages/zh-Hans/index.html b/site/pages/zh-Hans/index.html index 442e99f8..2bb220a0 100644 --- a/site/pages/zh-Hans/index.html +++ b/site/pages/zh-Hans/index.html @@ -110,11 +110,11 @@

历史

流量究竟去了哪?从秒到年,按应用、目标或 I

警报

哪里不对?逐应用、讲得清道理的警报,带上目标和阈值。

⌘5
-

规则

我拒绝过什么,真的生效了吗?每条规则、各自阻止了什么,以及每次判定的流水。

⌘8 · Developer ID / ⌘7 · App Store
+

策略

有哪些管控,它们是否生效?每项策略、实际状态,以及统一的活动记录。

⌘8 · Developer ID / ⌘7 · App Store

提问

用日常语言提问,从历史数据里作答——下方列出它执行过的每一条查询。

⌘6
-

来源

数据是否正在到达、多少已有名称、哪些设备通道处于活动状态?把收集器、设备和覆盖范围放在一处。

⌘9 · Developer ID / ⌘8 · App Store
+

来源

数据是否正在到达、哪些设备通道处于活动状态、Flowlight 能解释多少?一张页面从收集器一直看到覆盖范围。

⌘9 · Developer ID / ⌘8 · App Store

检查

可选。代理究竟发出了什么,是哪次工具调用引起的?解密后的请求、工具调用和结果。

⌘7 · Developer ID
diff --git a/site/pages/zh-Hans/privacy.html b/site/pages/zh-Hans/privacy.html index e7bc7b48..2d41dcb7 100644 --- a/site/pages/zh-Hans/privacy.html +++ b/site/pages/zh-Hans/privacy.html @@ -55,7 +55,7 @@

应用发出的网络请求

- + diff --git a/site/pages/zh-Hant/docs.html b/site/pages/zh-Hant/docs.html index afd7aefc..0bac5a38 100644 --- a/site/pages/zh-Hant/docs.html +++ b/site/pages/zh-Hant/docs.html @@ -30,7 +30,7 @@

使用 Flowlight

@@ -100,10 +100,10 @@

各個畫面

- - + +
请求发往包含什么什么时候
更新检查api.github.com一个查询最新版本的标准 HTTPS 请求;GitHub 会看到你的 IP 地址和 Flowlight/<version> 这个 User-Agent 标识启动时以及每天一次,自 0.1.2 起。关闭开关在设置 › 通用
更新下载github.com磁盘映像及其校验和文件只在你点击“下载并打开”时
官方规则包更新flowlight.xinbetween.com向固定官方源发送的标准 HTTPS 请求。服务器会看到你的 IP 地址、Flowlight/<version>用户代理和缓存条件验证器;不会收到任何流量、包设置或设备标识符启动后在检查到期时执行,此后在运行正常的情况下最多每天执行一次。默认开启;可在规则 › 包中关闭或手动选择立即检查
官方规则包更新flowlight.xinbetween.com向固定官方源发送的标准 HTTPS 请求。服务器会看到你的 IP 地址、Flowlight/<version>用户代理和缓存条件验证器;不会收到任何流量、包设置或设备标识符启动后在检查到期时执行,此后在运行正常的情况下最多每天执行一次。默认开启;可在策略 › 托管包中关闭或手动选择立即检查
反向 DNS你配置的 DNS 解析器在没有看到主机名时,查询某个 IP 地址登记的名称按需进行,在采样器捕获模式下,也就是网络扩展开始运行之前的默认模式。没有关闭开关:没有它,采样器就没法给大多数连接命名。网络扩展从流量本身给它们命名,因此不做这件事
网络所有者查询Team Cymru,通过 DNS没有已知主机名的公网 IP 地址。私有地址和保留地址绝不离开你的 Mac默认开启。关闭开关在捕获 › 识别网络所有者
用托管模型提问你选择的提供方:Anthropic、OpenAI、Google Gemini 或兼容的端点你的问题,以及模型选中的那些只读 Flowlight 查询所返回的结果。流量数据库本身不会被上传只在你选择了托管提供方并提交问题时。提问界面会显示发送出去的请求
警示⌘5所有看起來不尋常的事,附上 App、目標,以及觸發規則的那個數字。
提問⌘6用平常的話問你自己的流量,由本機模型或託管模型從記錄下來的歷史裡作答。
檢查⌘7 · Developer ID選用:你轉送經過 Flowlight 的代理,其解密後的請求,以及背後的工具呼叫。搜尋也會查進請求與回應的內文。
規則⌘8 · Developer ID
⌘7 · App Store
你設過的每一條允許清單和拒絕,各自管到哪裡,以及實際攔下了什麼。
來源⌘9 · Developer ID
⌘8 · App Store
在三個分頁中顯示收集器狀態、裝置頻道與命名涵蓋範圍。
政策⌘8 · Developer ID
⌘7 · App Store
每項已設定的政策、是否真的生效,以及整合後的唯讀活動記錄。
來源⌘9 · Developer ID
⌘8 · App Store
收集器狀態與復原、選用的裝置通道和涵蓋範圍,集中在一張可捲動的操作頁面中。
-

快速鍵遵循可見的側邊欄順序。Developer ID 版本在 ⌘7 包含「檢查」;App Store 版本省略它,因此「規則」與「來源」會移到 ⌘7 和 ⌘8。

+

快速鍵遵循可見的側邊欄順序。Developer ID 版本在 ⌘7 包含「檢查」;App Store 版本省略它,因此「政策」與「來源」會移到 ⌘7 和 ⌘8。

即時畫面:一列 App,各自帶著目前的傳送與接收速率,以及最忙的目標。
即時,Flowlight 開啟時的那個畫面。
@@ -142,7 +142,7 @@

工具與 MCP 伺服器

一組,每個網域都標明是從這四種裡的哪一種知道的。帶著好幾個標籤的網域,是比只帶一個標籤更強的事實:設定檔裡寫了而且確實連過, 比其中任何一項單獨說得更多;只被設定過的網域從來沒有被連上,而只被觀察到的網域從來沒有寫在任何你本來可以讀到的地方。

每個網域提供兩種拒絕:「對這個代理」,涵蓋該代理以及它啟動的工具;或是「在所有地方」,涵蓋這台 Mac 上的 - 每一個 App。兩者寫下的都是普通規則,所以「違規紀錄」會報告它們,規則編輯器會解釋它們 — 而且正因為它們是普通規則, + 每一個 App。兩者寫下的都是普通規則,所以「活動」會報告它們,規則編輯器會解釋它們 — 而且正因為它們是普通規則, 編輯器裡的模擬會在你把它打開之前告訴你,它本來會拒絕掉什麼。

有一類網域沒有按鈕。模型供應商自己去連的連接器 — 在請求內文裡聲明,從供應商那一側連過去 — 根本不會碰到這台 Mac,所以這裡的 任何規則都碰不到它。那一列會把這件事說出來,而不是遞給你一個默默什麼都不做的拒絕。如果同一個主機在本機也有設定,或者 @@ -309,16 +309,16 @@

模擬回應

會被連線:模擬改變的是答案,而不是連線能不能建立,所以「API 回傳 500」做得到,「API 完全連不上」則做不到。

-

規則

-

允許清單講的是某一個代理可以去哪裡。規則是它的通用版本:它會指名一個 +

政策

+

政策集中列出 Flowlight 能套用的所有管控:一般的允許或封鎖規則、代理允許清單、受管理套件,以及在支援 HTTPS 檢查時的防護機制、模擬回應、請求改寫和回應轉換。每一列都會區分設定狀態與實際效果:生效、僅回報、監看、等待、已排程、暫停、已用完、正在套用、需要檢閱、無法使用、不相容或失敗。選取一列會開啟負責該政策的對應編輯器;「政策」不會取代或混淆這些功能各自獨立的授權與持久化方式。

+

允許清單講的是某一個代理可以去哪裡。一般規則是它的通用版本:它會指名一個 對象 — 一個 App、一個代理、一個目標、一個 URL,或其中兩者的組合 — 然後說封鎖 或允許。在「即時」、「歷史」、「AI 代理」或「檢查」的任一列上按右鍵,就能針對你正在看的東西寫一條; - 每一條都會進到「規則」畫面的同一個編輯器,因為在你看不到的地方生效的規則, - 會是一條你再也找不到的規則。

+ 每條規則都能在「政策」中找到,並會在其權威編輯器中開啟。

範本。共二十二個,分成五組 — 阻止代理連到套件登錄或其他 模型供應商、關上資料外流的常見途徑(貼文網站、webhook 接收器、匿名檔案上傳、通道、DNS over HTTPS)、產品分析與當掉回報、工作時段政策,以及一次性的「先封鎖一小時,讓我弄清楚 - 這是什麼」。你可以從「規則」工具列瀏覽它們,或在「即時」、「歷史」、「AI 代理」或「檢查」的任一列上按右鍵, + 這是什麼」。你可以從「政策」工具列瀏覽它們,或在「即時」、「歷史」、「AI 代理」或「檢查」的任一列上按右鍵, 把範本套用到你正在看的東西上。

每個範本都會在動手之前先說明它要做什麼。會寫入多條規則的,會開一張卡片列出每一台 主機和時程;任何永久性的,會開啟已預先填好的編輯器;而只有會自己失效的規則 — @@ -339,8 +339,7 @@

規則

位址和連接埠,但完全不知道路徑,而且只在延伸功能模式下有效。HTTPS 檢查拒絕的是單一請求,所以它能比對 URL,並回覆一個代理讀得懂的狀態,而不是一個莫名死掉的 socket。nettop 取樣器完全無法 拒絕任何東西。目前的設定執行不了的規則,會在列表中說明這一點,而不是擺在那裡看起來像已生效。

-

第二個標籤頁是違規紀錄:每一條由規則決定的連線,包含 App、它要去哪裡、是哪一條 - 規則決定的,以及那是一次拒絕,還是一個放行的例外。

+

第二個畫面是唯讀的活動:一般規則判定、允許清單違規、受管理套件事件,以及各類檢查政策的效果,都依時間排列在一起。它會轉接現有的權威記錄,而不是把歷史改寫成一種籠統的事件模型;App Store 版本會省略依賴檢查的政策類型及其保留的活動。

它本來會做什麼。封鎖的規則寫起來容易,打開卻令人害怕,因為你通常最先知道的是它 弄壞了什麼 — 而且是後來才知道,從別處某個東西失敗上知道的。規則編輯器先回答這個問題:它拿記錄 @@ -358,7 +357,7 @@

規則

它能看到的最早的流量:一條只對照了兩小時歷史的規則,就是只對照了兩小時歷史。

受管理的廣告與追蹤器套件

-

規則 › 套件包含三個官方主機名稱清單:廣告、跨網站追蹤和隱私權與遙測。它們從一開始便可見,且預設全部關閉。封鎖功能需要網路延伸功能;nettop 取樣器只能觀察。

+

政策 › 受管理套件包含三個官方主機名稱清單:廣告、跨網站追蹤和隱私權與遙測。它們從一開始便可見,且預設全部關閉。封鎖功能需要網路延伸功能;nettop 取樣器只能觀察。

僅限官方簽署的更新。 Flowlight 會檢查一個固定的 HTTPS 來源,以尋找由發佈者簽署的新版目錄。自動檢查預設開啟,可在此處關閉。簽章可驗證內容,但不會啟用內容:新套件會保持關閉;如果目的地有所變更,已啟用的套件將停止執行,直到你檢閱確切的新版本並按下儲存。服務中斷期間,已接受的內容可能會繼續保留為已知正常的最後版本,並明確顯示為已過時。這些是主機名稱層級的網路封鎖,無法移除 DOM 或版面配置中的空白、瀏覽器覆疊層、由指令碼產生的彈出式視窗或原生應用程式視窗。

當 HTTPS 檢查將 App 轉送經過 Flowlight 的代理伺服器時,一般目的地規則和已啟用的受管理套件會在開啟任何來源伺服器連線之前,使用原 App 的身分作出判定。對於使用憑證綁定或始終不解密的 CONNECT 通道,這項判定會按主機名稱進行;代理的允許清單仍只適用於直接經過網路延伸功能的路徑。如果擁有者或主機名稱未知,則會允許連線。

@@ -407,7 +406,8 @@

代理防護機制:封鎖一個工具、一個伺服器或

這不是沙箱。被拒絕的工具不會再被提供,也不會再得到回應。但仍然有 shell 的代理,可以自己動手做那個工具原本要做的事。Flowlight 會把那件事記錄下來;它不會假裝自己能阻止。

-

來源:收集器與引擎

+

來源:收集器、裝置與涵蓋範圍

+

來源是一張可捲動的操作頁面:先是收集器,接著是標題清楚的裝置與涵蓋範圍區段。狀態通知、提問答案、還原後的視窗和螢幕截圖連結都會捲動到相關區段並短暫標示,同時不隱藏其他內容。

Flowlight 用兩種方式之一記錄流量,在「來源 › 收集器」中選擇。兩者會漏掉的東西不同,在你倚賴 任何一種之前,值得先了解。

@@ -593,7 +593,7 @@

常見問答

視窗關著時,Flowlight 幾乎什麼都不做:只維持選單列上的速率,並每秒寫入一列資料。網路延伸功能 比備用的取樣器更省,後者每秒都會啟動一個短命的 nettop 程序。
它能封鎖連線嗎?
-
可以,搭配網路延伸功能。代理的允許清單除了警示也能拒絕,規則可以封鎖一個 App、 +
可以,搭配網路延伸功能。代理的允許清單除了警示也能拒絕,規則可以封鎖一個 App、 一個目標或一個 URL,時間長短由你決定,而防護機制可以在工具送到模型面前之前, 先把它從代理手上拿走。在你要求之前不會封鎖任何東西,而每一次拒絕都帶著它的退路。
我一定要用網路延伸功能嗎?
diff --git a/site/pages/zh-Hant/index.html b/site/pages/zh-Hant/index.html index 63900827..f4eeb05d 100644 --- a/site/pages/zh-Hant/index.html +++ b/site/pages/zh-Hant/index.html @@ -110,11 +110,11 @@

歷史

流量都到哪去了?從秒到年,依 App、目標或 IP

警示

哪裡看起來不對?每個 App 都有說得清楚的警示,附上目標和閾值。

⌘5
-

規則

我拒絕了什麼,真的有在擋嗎?每一條規則、各自封鎖過什麼,以及每一次決定的紀錄。

⌘8 · Developer ID / ⌘7 · App Store
+

政策

有哪些管控,又是否真的生效?每項政策、實際狀態,以及整合後的活動記錄。

⌘8 · Developer ID / ⌘7 · App Store

提問

用日常語言提問,從歷史記錄裡得到答案 — 下方還列出它跑過的每一條查詢。

⌘6
-

來源

資料是否正在到達、有多少已有名稱、哪些裝置通道正在使用?把收集器、裝置和涵蓋範圍放在同一處。

⌘9 · Developer ID / ⌘8 · App Store
+

來源

資料是否正在到達、哪些裝置通道正在使用、Flowlight 能交代多少?一張頁面從收集器一路看到涵蓋範圍。

⌘9 · Developer ID / ⌘8 · App Store

檢查

選用功能。代理實際送出了什麼,又是哪一次工具呼叫造成的?解密後的請求、工具呼叫、結果。

⌘7 · Developer ID
diff --git a/site/pages/zh-Hant/privacy.html b/site/pages/zh-Hant/privacy.html index de8bf30e..16b4ab3a 100644 --- a/site/pages/zh-Hant/privacy.html +++ b/site/pages/zh-Hant/privacy.html @@ -55,7 +55,7 @@

這個 App 發出的網路請求

- + diff --git a/templates/inspection-plugin/README.md b/templates/inspection-plugin/README.md index 25889637..8301f205 100644 --- a/templates/inspection-plugin/README.md +++ b/templates/inspection-plugin/README.md @@ -20,6 +20,8 @@ Evidence must be bounded and redacted. Do not include raw request or response bo Define a synchronous `evaluate(context)` function. Flowlight passes `context.manifest` and `context.exchange`; the exchange contains only the sanitized metadata allowed by the plugin's declared permissions. It never contains request bodies, response bodies, header values, prompts, completions or full tool inputs. +`context.manifest.configuration` contains this plugin's local string settings. `context.manifest.providerProfiles` is a separate array of enabled local profiles assigned to the plugin with the same guardrail provider. Each profile contains only `id`, `provider`, optional `endpoint`, and non-secret `configuration`. Flowlight never passes credential values or Keychain references to JavaScript, and profiles do not initiate network requests. + Return up to 8 findings. Each finding uses `severity`, `title`, `summary`, bounded `evidence`, and optional `suggestedActions`. Actions can be `guardrail`, `networkRule`, `mockResponse`, `requestRewrite`, or `responseTransform`; each opens the matching Flowlight editor with bounded prefilled fields and is saved only if the user reviews and clicks Save. Suggestions require the `suggestAction` capability and permission. Scripts run out of process with an empty environment, a 128 KB script limit and a short timeout. Failures produce no findings. ## Files
請求送到哪裡內容是什麼什麼時候
更新檢查api.github.com一個查詢最新版本的標準 HTTPS 請求;GitHub 會看到你的 IP 位址和 Flowlight/<version> 這個 user agent啟動時以及每天一次,自 0.1.2 起。關閉開關在「設定」› 「一般」
更新下載github.com磁碟映像檔及其總和檢查碼檔案只有在你點「下載並開啟」時
官方規則套件更新flowlight.xinbetween.com向固定官方來源傳送的標準 HTTPS 要求。伺服器會看到你的 IP 位址、Flowlight/<version>使用者代理程式和條件式快取驗證器;不會收到任何流量、套件設定或裝置識別碼啟動後在檢查到期時執行,此後在運作正常的情況下每天最多執行一次。預設開啟;可在規則 › 套件中關閉或手動選擇立即檢查
官方規則套件更新flowlight.xinbetween.com向固定官方來源傳送的標準 HTTPS 要求。伺服器會看到你的 IP 位址、Flowlight/<version>使用者代理程式和條件式快取驗證器;不會收到任何流量、套件設定或裝置識別碼啟動後在檢查到期時執行,此後在運作正常的情況下每天最多執行一次。預設開啟;可在「政策 › 受管理套件」中關閉或手動選擇立即檢查
反向 DNS你設定的 DNS 解析器在沒有看到主機名稱時,查詢某個 IP 位址登記的名稱需要時進行,在取樣器擷取模式下,也就是網路延伸功能開始運作之前的預設模式。沒有關閉開關:少了它,取樣器就沒辦法為大多數連線命名。網路延伸功能會從流量本身為它們命名,因此不做這件事
網路擁有者查詢Team Cymru,透過 DNS沒有已知主機名稱的公開 IP 位址。私有和保留位址絕不會離開你的 Mac預設開啟。關閉開關在「擷取」› 「辨識網路擁有者」
用託管模型提問你選擇的供應商:Anthropic、OpenAI、Google Gemini 或相容的端點你的問題,以及模型所選用的 Flowlight 唯讀查詢回傳的結果。流量資料庫本身不會被上傳只有在你選擇託管供應商並送出問題時。「提問」畫面會顯示送出的請求