@agentwhy/cli 0.0.0-dev
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +181 -0
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/dist/adapter/claude-code/contract/assumptions.js +208 -0
- package/dist/adapter/claude-code/contract/denials.js +15 -0
- package/dist/adapter/claude-code/contract/entry-points.js +15 -0
- package/dist/adapter/claude-code/contract/fields.js +73 -0
- package/dist/adapter/claude-code/contract/hooks.js +96 -0
- package/dist/adapter/claude-code/contract/identifiers.js +15 -0
- package/dist/adapter/claude-code/contract/layout.js +29 -0
- package/dist/adapter/claude-code/contract/line-types.js +26 -0
- package/dist/adapter/claude-code/contract/projects.js +23 -0
- package/dist/adapter/claude-code/contract/recognition.js +14 -0
- package/dist/adapter/claude-code/contract/session-title.js +20 -0
- package/dist/adapter/claude-code/contract/settings.js +27 -0
- package/dist/adapter/claude-code/contract/task-notifications.js +33 -0
- package/dist/adapter/claude-code/contract/tools.js +37 -0
- package/dist/adapter/claude-code/contract/version.js +51 -0
- package/dist/adapter/claude-code/discovery/claude-code-project-catalogue.js +129 -0
- package/dist/adapter/claude-code/discovery/claude-code-recognition.js +46 -0
- package/dist/adapter/claude-code/discovery/claude-code-session-catalogue.js +100 -0
- package/dist/adapter/claude-code/discovery/claude-code-session-discovery.js +61 -0
- package/dist/adapter/claude-code/discovery/claude-code-session-titles.js +28 -0
- package/dist/adapter/claude-code/discovery/discovered-session.js +1 -0
- package/dist/adapter/claude-code/discovery/session-entries.js +41 -0
- package/dist/adapter/claude-code/discovery/transcript-tail.js +81 -0
- package/dist/adapter/claude-code/events/claude-code-session-source.js +197 -0
- package/dist/adapter/claude-code/events/delegation-index.js +15 -0
- package/dist/adapter/claude-code/events/transcript-scan.js +179 -0
- package/dist/adapter/claude-code/hooks/hook-output.js +42 -0
- package/dist/adapter/claude-code/hooks/pre-tool-use-input.js +23 -0
- package/dist/adapter/claude-code/hooks/stop-input.js +25 -0
- package/dist/adapter/claude-code/hooks/subagent-stop-input.js +26 -0
- package/dist/adapter/claude-code/policy/deny-rules.js +46 -0
- package/dist/adapter/claude-code/probe/claude-code-probe.js +86 -0
- package/dist/adapter/claude-code/probe/collectors/agent-tool-input-key-collector.js +24 -0
- package/dist/adapter/claude-code/probe/collectors/denial-kind-collector.js +20 -0
- package/dist/adapter/claude-code/probe/collectors/denied-call-collector.js +11 -0
- package/dist/adapter/claude-code/probe/collectors/identifier-collector.js +19 -0
- package/dist/adapter/claude-code/probe/collectors/line-collector.js +1 -0
- package/dist/adapter/claude-code/probe/collectors/line-type-collector.js +29 -0
- package/dist/adapter/claude-code/probe/collectors/sidechain-collector.js +20 -0
- package/dist/adapter/claude-code/probe/collectors/task-notification-collector.js +51 -0
- package/dist/adapter/claude-code/probe/collectors/tool-result-reference-collector.js +31 -0
- package/dist/adapter/claude-code/probe/collectors/tool-use-collector.js +24 -0
- package/dist/adapter/claude-code/probe/collectors/tool-version-collector.js +14 -0
- package/dist/adapter/claude-code/probe/collectors/top-level-key-collector.js +14 -0
- package/dist/adapter/claude-code/probe/collectors/unknown-line-type-key-collector.js +24 -0
- package/dist/adapter/claude-code/probe/collectors/working-directory-collector.js +28 -0
- package/dist/adapter/claude-code/probe/doctor-report.js +1 -0
- package/dist/adapter/claude-code/probe/meta-tally.js +42 -0
- package/dist/adapter/claude-code/probe/session-probe.js +1 -0
- package/dist/adapter/claude-code/probe/session-tally.js +41 -0
- package/dist/adapter/claude-code/probe/transcript-line.js +30 -0
- package/dist/adapter/claude-code/probe/transcript-tally.js +74 -0
- package/dist/adapter/claude-code/settings/deny-entries.js +65 -0
- package/dist/adapter/claude-code/settings/hook-entries.js +303 -0
- package/dist/adapter/codex/contract/actions.js +69 -0
- package/dist/adapter/codex/contract/assumptions.js +55 -0
- package/dist/adapter/codex/contract/capabilities.js +24 -0
- package/dist/adapter/codex/contract/delegations.js +30 -0
- package/dist/adapter/codex/contract/deliveries.js +25 -0
- package/dist/adapter/codex/contract/envelope.js +33 -0
- package/dist/adapter/codex/contract/hook-refusals.js +14 -0
- package/dist/adapter/codex/contract/hooks.js +46 -0
- package/dist/adapter/codex/contract/messages.js +47 -0
- package/dist/adapter/codex/contract/permissions.js +27 -0
- package/dist/adapter/codex/contract/reviews.js +13 -0
- package/dist/adapter/codex/contract/session.js +31 -0
- package/dist/adapter/codex/contract/turns.js +23 -0
- package/dist/adapter/codex/contract/version.js +15 -0
- package/dist/adapter/codex/discovery/codex-conversations.js +44 -0
- package/dist/adapter/codex/discovery/codex-project-catalogue.js +49 -0
- package/dist/adapter/codex/discovery/codex-recognition.js +20 -0
- package/dist/adapter/codex/discovery/codex-session-catalogue.js +28 -0
- package/dist/adapter/codex/discovery/codex-session-discovery.js +115 -0
- package/dist/adapter/codex/discovery/codex-session-index.js +21 -0
- package/dist/adapter/codex/discovery/codex-session-titles.js +135 -0
- package/dist/adapter/codex/discovery/session-header.js +86 -0
- package/dist/adapter/codex/discovery/session-owners.js +11 -0
- package/dist/adapter/codex/discovery/session-roots.js +49 -0
- package/dist/adapter/codex/events/action-items.js +138 -0
- package/dist/adapter/codex/events/capability-records.js +26 -0
- package/dist/adapter/codex/events/codex-session-source.js +121 -0
- package/dist/adapter/codex/events/hook-refusals.js +27 -0
- package/dist/adapter/codex/events/rollout-scan.js +614 -0
- package/dist/adapter/codex/events/turn-permissions.js +60 -0
- package/dist/adapter/codex/hooks/pre-tool-use-input.js +24 -0
- package/dist/adapter/codex/hooks/stop-refusals.js +57 -0
- package/dist/adapter/codex/probe/codex-doctor-report.js +1 -0
- package/dist/adapter/codex/probe/codex-probe.js +268 -0
- package/dist/adapter/codex/settings/codex-hooks.js +83 -0
- package/dist/cli/cli-command.js +1 -0
- package/dist/cli/command-router.js +42 -0
- package/dist/cli/commands/check-cli-command.js +128 -0
- package/dist/cli/commands/codex-stop-cli-command.js +27 -0
- package/dist/cli/commands/doctor-arguments.js +32 -0
- package/dist/cli/commands/doctor-cli-command.js +31 -0
- package/dist/cli/commands/doctor-usage.js +14 -0
- package/dist/cli/commands/init-cli-command.js +123 -0
- package/dist/cli/commands/menu-cli-command.js +58 -0
- package/dist/cli/commands/notify-cli-command.js +158 -0
- package/dist/cli/commands/refuse-cli-command.js +79 -0
- package/dist/cli/commands/report-arguments.js +62 -0
- package/dist/cli/commands/report-cli-command.js +36 -0
- package/dist/cli/commands/report-usage.js +41 -0
- package/dist/cli/commands/sessions-cli-command.js +184 -0
- package/dist/cli/commands/start-cli-command.js +83 -0
- package/dist/cli/commands/watch-cli-command.js +142 -0
- package/dist/cli/exit-codes.js +13 -0
- package/dist/cli/project-switches.js +46 -0
- package/dist/cli.js +80 -0
- package/dist/composition-root.js +411 -0
- package/dist/core/access/command-line.js +454 -0
- package/dist/core/access/listing.js +58 -0
- package/dist/core/access/path-shape.js +59 -0
- package/dist/core/access/path-tokens.js +37 -0
- package/dist/core/access/protected-access.js +310 -0
- package/dist/core/access/protected-values.js +102 -0
- package/dist/core/access/recorded-effect.js +67 -0
- package/dist/core/access/returns.js +173 -0
- package/dist/core/access/search-output.js +34 -0
- package/dist/core/access/search-reach.js +268 -0
- package/dist/core/access/uses.js +143 -0
- package/dist/core/access/words-before.js +52 -0
- package/dist/core/agent.js +1 -0
- package/dist/core/capability.js +11 -0
- package/dist/core/combined-project-catalogue.js +36 -0
- package/dist/core/combined-session-catalogue.js +25 -0
- package/dist/core/completeness.js +10 -0
- package/dist/core/context.js +1 -0
- package/dist/core/correlation/correlate.js +329 -0
- package/dist/core/delegation.js +1 -0
- package/dist/core/detector/prompt-signals.js +78 -0
- package/dist/core/entry-point.js +1 -0
- package/dist/core/event.js +1 -0
- package/dist/core/evidence.js +9 -0
- package/dist/core/late-message.js +32 -0
- package/dist/core/message.js +1 -0
- package/dist/core/nearest-project.js +28 -0
- package/dist/core/policy/default-policy.js +32 -0
- package/dist/core/policy/glob.js +56 -0
- package/dist/core/policy/parse-policy.js +70 -0
- package/dist/core/policy/policy.js +20 -0
- package/dist/core/policy/resolve-policy.js +16 -0
- package/dist/core/project-catalogue.js +1 -0
- package/dist/core/project-root.js +133 -0
- package/dist/core/provider-titles.js +10 -0
- package/dist/core/redaction/entropy.js +46 -0
- package/dist/core/redaction/redacted.js +4 -0
- package/dist/core/redaction/redactor.js +339 -0
- package/dist/core/redaction/secret-patterns.js +22 -0
- package/dist/core/redaction/value-shapes.js +81 -0
- package/dist/core/redaction/value-trace.js +110 -0
- package/dist/core/review.js +1 -0
- package/dist/core/session-catalogue.js +19 -0
- package/dist/core/session-filter.js +42 -0
- package/dist/core/session-format.js +27 -0
- package/dist/core/session-model.js +1 -0
- package/dist/core/session-reader.js +22 -0
- package/dist/core/session-records.js +1 -0
- package/dist/core/session-source.js +1 -0
- package/dist/core/session-titles.js +21 -0
- package/dist/core/turn.js +1 -0
- package/dist/doctor/codex-doctor.js +14 -0
- package/dist/doctor/doctor-use-case.js +1 -0
- package/dist/doctor/format-selecting-doctor.js +28 -0
- package/dist/doctor/render/attention/attention-rule.js +1 -0
- package/dist/doctor/render/attention/doctor-attention-rules.js +28 -0
- package/dist/doctor/render/attention/rules/damaged-meta-files-rule.js +8 -0
- package/dist/doctor/render/attention/rules/damaged-transcripts-rule.js +9 -0
- package/dist/doctor/render/attention/rules/incomplete-subagent-pairs-rule.js +6 -0
- package/dist/doctor/render/attention/rules/missing-main-transcript-rule.js +5 -0
- package/dist/doctor/render/attention/rules/missing-spilled-results-rule.js +6 -0
- package/dist/doctor/render/attention/rules/multiple-working-directories-rule.js +15 -0
- package/dist/doctor/render/attention/rules/unknown-denial-kinds-rule.js +5 -0
- package/dist/doctor/render/attention/rules/unknown-line-types-rule.js +6 -0
- package/dist/doctor/render/attention/rules/unrecognised-entries-rule.js +6 -0
- package/dist/doctor/render/attention/rules/unusable-directories-rule.js +12 -0
- package/dist/doctor/render/attention/transcript-scopes.js +6 -0
- package/dist/doctor/render/codex-text-doctor-renderer.js +97 -0
- package/dist/doctor/render/doctor-renderers.js +1 -0
- package/dist/doctor/render/json-doctor-renderer.js +7 -0
- package/dist/doctor/render/output-format.js +1 -0
- package/dist/doctor/render/text-doctor-renderer.js +124 -0
- package/dist/doctor/session-doctor.js +16 -0
- package/dist/infrastructure/clack-asker.js +24 -0
- package/dist/infrastructure/clack-chooser.js +40 -0
- package/dist/infrastructure/clack-multi-chooser.js +31 -0
- package/dist/infrastructure/file-alert-store.js +120 -0
- package/dist/infrastructure/file-checked-store.js +55 -0
- package/dist/infrastructure/file-mark-store.js +67 -0
- package/dist/infrastructure/file-onboarding-store.js +93 -0
- package/dist/infrastructure/folder-window.js +26 -0
- package/dist/infrastructure/node-agentwhy-invocation.js +99 -0
- package/dist/infrastructure/node-browser.js +40 -0
- package/dist/infrastructure/node-file-system.js +111 -0
- package/dist/infrastructure/node-local-server.js +115 -0
- package/dist/infrastructure/node-text-input.js +26 -0
- package/dist/infrastructure/os-notifier.js +21 -0
- package/dist/infrastructure/osascript-folder-chooser.js +53 -0
- package/dist/infrastructure/powershell-folder-chooser.js +78 -0
- package/dist/infrastructure/real-directory.js +18 -0
- package/dist/infrastructure/stream-printer.js +11 -0
- package/dist/infrastructure/terminal-banner.js +19 -0
- package/dist/ports/agentwhy-invocation.js +1 -0
- package/dist/ports/alert-store.js +1 -0
- package/dist/ports/asker.js +1 -0
- package/dist/ports/browser.js +1 -0
- package/dist/ports/checked-store.js +1 -0
- package/dist/ports/chooser.js +1 -0
- package/dist/ports/directory-reader.js +1 -0
- package/dist/ports/file-access-error.js +11 -0
- package/dist/ports/file-reader.js +1 -0
- package/dist/ports/file-tail-reader.js +1 -0
- package/dist/ports/file-writer.js +1 -0
- package/dist/ports/folder-chooser.js +1 -0
- package/dist/ports/local-server.js +1 -0
- package/dist/ports/mark-store.js +10 -0
- package/dist/ports/multi-chooser.js +1 -0
- package/dist/ports/notifier.js +1 -0
- package/dist/ports/onboarding-store.js +1 -0
- package/dist/ports/printer.js +1 -0
- package/dist/ports/text-input.js +1 -0
- package/dist/refuse/command-reach.js +135 -0
- package/dist/refuse/command-refusal.js +88 -0
- package/dist/refuse/render/codex-stop-words.js +21 -0
- package/dist/refuse/render/refusal-words.js +34 -0
- package/dist/report/build-report.js +1037 -0
- package/dist/report/check/actions-digest.js +73 -0
- package/dist/report/check/check-lines.js +83 -0
- package/dist/report/check/mark-request.js +90 -0
- package/dist/report/check/marks.js +84 -0
- package/dist/report/check/render/text-digest-renderer.js +332 -0
- package/dist/report/check/session-actions.js +90 -0
- package/dist/report/check/session-check.js +91 -0
- package/dist/report/choose-policy.js +61 -0
- package/dist/report/flow-reads.js +18 -0
- package/dist/report/private-files/tell-lists.js +84 -0
- package/dist/report/project-rules.js +33 -0
- package/dist/report/render/home-relative.js +18 -0
- package/dist/report/render/html-head.js +26 -0
- package/dist/report/render/html-report-components.js +48 -0
- package/dist/report/render/html-report-styles.js +500 -0
- package/dist/report/render/html-sidebar.js +42 -0
- package/dist/report/render/path-tail.js +14 -0
- package/dist/report/render/report-copy.js +1177 -0
- package/dist/report/render/report-page/advanced-view.js +167 -0
- package/dist/report/render/report-page/file-story.js +114 -0
- package/dist/report/render/report-page/files-view.js +381 -0
- package/dist/report/render/report-page/files.js +77 -0
- package/dist/report/render/report-page/fix-wizard-script.js +269 -0
- package/dist/report/render/report-page/fix-wizard.js +310 -0
- package/dist/report/render/report-page/helpers-view.js +383 -0
- package/dist/report/render/report-page/helpers.js +51 -0
- package/dist/report/render/report-page/item-names.js +63 -0
- package/dist/report/render/report-page/protect-patterns.js +13 -0
- package/dist/report/render/report-page/providers.js +208 -0
- package/dist/report/render/report-page/record-view.js +367 -0
- package/dist/report/render/report-page/report-page-renderer.js +129 -0
- package/dist/report/render/report-page/report-views.js +31 -0
- package/dist/report/render/report-page/story-window.js +356 -0
- package/dist/report/render/report-page/times.js +25 -0
- package/dist/report/render/report-page/to-do-view.js +299 -0
- package/dist/report/render/report-page/to-do.js +152 -0
- package/dist/report/render/report-page.js +1 -0
- package/dist/report/render/session-view.js +250 -0
- package/dist/report/render/text-report-renderer.js +981 -0
- package/dist/report/render/ui/add-file-popup.js +122 -0
- package/dist/report/render/ui/app-sidebar.js +78 -0
- package/dist/report/render/ui/ask-panel.js +80 -0
- package/dist/report/render/ui/avatar.js +19 -0
- package/dist/report/render/ui/backdrop.js +29 -0
- package/dist/report/render/ui/brand-mark.js +4 -0
- package/dist/report/render/ui/button.js +67 -0
- package/dist/report/render/ui/callout.js +20 -0
- package/dist/report/render/ui/chat-example.js +17 -0
- package/dist/report/render/ui/checklist.js +50 -0
- package/dist/report/render/ui/confirm-dialog.js +34 -0
- package/dist/report/render/ui/data-table.js +49 -0
- package/dist/report/render/ui/drawer.js +25 -0
- package/dist/report/render/ui/file-chip.js +37 -0
- package/dist/report/render/ui/fold-line.js +22 -0
- package/dist/report/render/ui/guide-card.js +25 -0
- package/dist/report/render/ui/hero.js +37 -0
- package/dist/report/render/ui/labelled-select.js +10 -0
- package/dist/report/render/ui/live-script.js +222 -0
- package/dist/report/render/ui/local-date.js +58 -0
- package/dist/report/render/ui/mode-icon.js +6 -0
- package/dist/report/render/ui/page-shell.js +101 -0
- package/dist/report/render/ui/pill-tabs.js +47 -0
- package/dist/report/render/ui/popup.js +77 -0
- package/dist/report/render/ui/progress.js +35 -0
- package/dist/report/render/ui/project-list.js +284 -0
- package/dist/report/render/ui/scope-choice.js +19 -0
- package/dist/report/render/ui/stats.js +21 -0
- package/dist/report/render/ui/status-icon.js +30 -0
- package/dist/report/render/ui/status-look.js +21 -0
- package/dist/report/render/ui/switch.js +11 -0
- package/dist/report/render/ui/tag.js +19 -0
- package/dist/report/render/ui/task-list.js +56 -0
- package/dist/report/render/ui/tokens.js +46 -0
- package/dist/report/render/ui/update-notice.js +146 -0
- package/dist/report/render/ui/words/app-words.js +163 -0
- package/dist/report/render/ui/words/conversations-words.js +457 -0
- package/dist/report/render/ui/words/month-words.js +59 -0
- package/dist/report/render/ui/words/onboarding-words.js +446 -0
- package/dist/report/render/ui/words/projects-words.js +217 -0
- package/dist/report/render/ui/words/report-words.js +2099 -0
- package/dist/report/render/ui/words/settings-words.js +611 -0
- package/dist/report/render/ui/words/to-fix-words.js +287 -0
- package/dist/report/report-model.js +2 -0
- package/dist/report/report-use-case.js +1 -0
- package/dist/report/rule-names.js +20 -0
- package/dist/report/session-report.js +163 -0
- package/dist/report/start/app-nav.js +37 -0
- package/dist/report/start/conversations/calendar-view.js +104 -0
- package/dist/report/start/conversations/conversation-columns.js +187 -0
- package/dist/report/start/conversations/conversations-renderer.js +54 -0
- package/dist/report/start/conversations/empty-week.js +120 -0
- package/dist/report/start/conversations/files-window.js +166 -0
- package/dist/report/start/conversations/period-section.js +280 -0
- package/dist/report/start/conversations/periods-script.js +440 -0
- package/dist/report/start/conversations/periods.js +110 -0
- package/dist/report/start/conversations/week-view.js +30 -0
- package/dist/report/start/conversations/weeks.js +17 -0
- package/dist/report/start/month/month-renderer.js +50 -0
- package/dist/report/start/month/month-view.js +135 -0
- package/dist/report/start/month/months.js +17 -0
- package/dist/report/start/onboarding/done.js +171 -0
- package/dist/report/start/onboarding/finish-onboarding.js +93 -0
- package/dist/report/start/onboarding/intro.js +100 -0
- package/dist/report/start/onboarding/onboarding-changes.js +40 -0
- package/dist/report/start/onboarding/onboarding-renderer.js +195 -0
- package/dist/report/start/onboarding/onboarding-script.js +400 -0
- package/dist/report/start/onboarding/onboarding-view.js +98 -0
- package/dist/report/start/onboarding/project-step.js +210 -0
- package/dist/report/start/onboarding/steps.js +239 -0
- package/dist/report/start/onboarding/welcome.js +66 -0
- package/dist/report/start/page-notice.js +10 -0
- package/dist/report/start/projects/index-projects.js +43 -0
- package/dist/report/start/projects/moved-page.js +82 -0
- package/dist/report/start/projects/project-switch.js +138 -0
- package/dist/report/start/projects/projects-window.js +67 -0
- package/dist/report/start/projects/temporary-space.js +18 -0
- package/dist/report/start/render/air-datepicker-vendor.js +27 -0
- package/dist/report/start/render/session-status.js +41 -0
- package/dist/report/start/render/start-words.js +84 -0
- package/dist/report/start/repository.js +28 -0
- package/dist/report/start/serve/index-handler.js +372 -0
- package/dist/report/start/serve/onboarding-request.js +50 -0
- package/dist/report/start/serve/settings-request.js +1 -0
- package/dist/report/start/serve/settings-setup.js +193 -0
- package/dist/report/start/session-index.js +1 -0
- package/dist/report/start/session-start.js +974 -0
- package/dist/report/start/settings/alerts-tab.js +104 -0
- package/dist/report/start/settings/files-tab.js +169 -0
- package/dist/report/start/settings/general-tab.js +71 -0
- package/dist/report/start/settings/settings-renderer.js +254 -0
- package/dist/report/start/settings/settings-script.js +161 -0
- package/dist/report/start/settings/settings-view.js +240 -0
- package/dist/report/start/settings/settings-windows.js +334 -0
- package/dist/report/start/settings-files.js +43 -0
- package/dist/report/start/to-fix/done-list.js +45 -0
- package/dist/report/start/to-fix/file-window.js +110 -0
- package/dist/report/start/to-fix/to-fix-list.js +70 -0
- package/dist/report/start/to-fix/to-fix-renderer.js +237 -0
- package/dist/report/start/to-fix/to-fix-script.js +94 -0
- package/dist/report/start/to-fix/to-fix-view.js +124 -0
- package/dist/report/watch/agent-alert.js +72 -0
- package/dist/report/watch/notice-choices.js +35 -0
- package/dist/report/watch/notice-settings.js +65 -0
- package/dist/report/watch/preferences.js +100 -0
- package/dist/report/watch/render/instruction-words.js +54 -0
- package/dist/report/watch/render/notice-words.js +182 -0
- package/dist/report/watch/subagent-watch.js +345 -0
- package/dist/report/watch/watch-notice.js +1 -0
- package/dist/setup/behind.js +18 -0
- package/dist/setup/codex-mirror.js +171 -0
- package/dist/setup/not-a-project.js +19 -0
- package/dist/setup/project-setup.js +644 -0
- package/dist/setup/protected-patterns.js +30 -0
- package/dist/shared/colour.js +23 -0
- package/dist/shared/compare.js +4 -0
- package/dist/shared/content-version.js +13 -0
- package/dist/shared/counter.js +11 -0
- package/dist/shared/file-stamp.js +4 -0
- package/dist/shared/json.js +13 -0
- package/dist/shared/label.js +13 -0
- package/dist/shared/package-name.js +6 -0
- package/dist/shared/plain-invocation.js +44 -0
- package/dist/shared/plain-version.js +21 -0
- package/dist/shared/printable.js +25 -0
- package/dist/shared/renderer.js +1 -0
- package/dist/shared/sentence.js +20 -0
- package/dist/shared/terminal-logo.js +41 -0
- package/dist/shared/text-table.js +15 -0
- package/dist/shared/wrap.js +29 -0
- package/package.json +70 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { matchesGlob } from './glob.js';
|
|
2
|
+
/**
|
|
3
|
+
* The entry that protects this path, or undefined. An exception wins over every protecting pattern, so a path
|
|
4
|
+
* someone deliberately excluded stays excluded however many rules would otherwise cover it.
|
|
5
|
+
*/
|
|
6
|
+
export function protectionOf(policy, path) {
|
|
7
|
+
// A directory is written both ways - `ls secrets` and `cat secrets/x` - while `**/secrets/**` only matches
|
|
8
|
+
// the form with the separator. Both are tried, so how the command happened to be typed does not decide.
|
|
9
|
+
const forms = path.endsWith('/') ? [path] : [path, `${path}/`];
|
|
10
|
+
if (forms.some((form) => policy.allowed.some((pattern) => matchesGlob(form, pattern))))
|
|
11
|
+
return undefined;
|
|
12
|
+
return policy.protected.find((entry) => forms.some((form) => matchesGlob(form, entry.pattern)));
|
|
13
|
+
}
|
|
14
|
+
export function protects(policy, path) {
|
|
15
|
+
return protectionOf(policy, path) !== undefined;
|
|
16
|
+
}
|
|
17
|
+
/** The same policy with only what is kept from the agent: what a hook that stops a command may act on (F57). */
|
|
18
|
+
export function blockingOnly(policy) {
|
|
19
|
+
return { ...policy, protected: policy.protected.filter((entry) => entry.mode !== 'tell') };
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { DEFAULT_POLICY } from './default-policy.js';
|
|
2
|
+
/**
|
|
3
|
+
* Chooses the policy: an explicit file, else the environment's own deny rules, else the built-in default.
|
|
4
|
+
*
|
|
5
|
+
* **A policy file that cannot be read is an error, never a fallback.** Falling back would run the whole analysis
|
|
6
|
+
* under rules nobody chose while the header claimed a policy was in force - the failure mode this tool exists to
|
|
7
|
+
* describe, in its own output. Absence is a different thing from breakage: absence falls through, breakage stops.
|
|
8
|
+
*/
|
|
9
|
+
export function resolvePolicy(candidates) {
|
|
10
|
+
const { file, settings } = candidates;
|
|
11
|
+
if (file !== undefined)
|
|
12
|
+
return 'errors' in file ? { errors: file.errors } : { policy: file.policy };
|
|
13
|
+
if (settings !== undefined)
|
|
14
|
+
return { policy: settings };
|
|
15
|
+
return { policy: DEFAULT_POLICY };
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One recorded directory is the root. None and several are both "no root" - but they are different answers to
|
|
3
|
+
* the reader, so they stay different here rather than collapsing into `undefined`.
|
|
4
|
+
*/
|
|
5
|
+
export function projectRootOf(directories) {
|
|
6
|
+
const distinct = [...new Set(directories)];
|
|
7
|
+
const [only] = distinct;
|
|
8
|
+
if (distinct.length === 1 && only !== undefined)
|
|
9
|
+
return { kind: 'known', path: only };
|
|
10
|
+
return distinct.length === 0 ? { kind: 'absent' } : { kind: 'ambiguous', count: distinct.length };
|
|
11
|
+
}
|
|
12
|
+
/** How the root itself is shown once paths are relative to it. An empty string would render as no path at all. */
|
|
13
|
+
export const PROJECT_ROOT_ITSELF = '.';
|
|
14
|
+
/** What a path outside the project becomes in a shared report: the signal kept, the location dropped (R3). */
|
|
15
|
+
export const OUTSIDE_THE_PROJECT = 'outside the project';
|
|
16
|
+
/** Either separator: a transcript recorded on Windows carries the other one, and its paths are paths too. */
|
|
17
|
+
const SEPARATOR = /[\\/]/;
|
|
18
|
+
/** The characters a path is made of. What sits next to a match decides whether the match is a path at all. */
|
|
19
|
+
const PATH_CHARACTER = String.raw `[\w.~@+\-\\/]`;
|
|
20
|
+
/**
|
|
21
|
+
* Absolute means "says where it starts": at the root, at a home directory, or at a drive. Anything else was
|
|
22
|
+
* recorded **relative to the working directory**, which is to say inside the project - that is what makes
|
|
23
|
+
* `apps/web/.env` from a shell command a path inside the project and not one above it.
|
|
24
|
+
*/
|
|
25
|
+
function isAbsolute(value) {
|
|
26
|
+
return value.startsWith('/') || value.startsWith('~') || /^[A-Za-z]:[\\/]/.test(value);
|
|
27
|
+
}
|
|
28
|
+
/** The root without its trailing separator, whichever kind it was written with. */
|
|
29
|
+
function baseOf(root) {
|
|
30
|
+
return SEPARATOR.test(root.path.slice(-1)) ? root.path.slice(0, -1) : root.path;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Whether a path walks back up through a `..` segment. Comparing prefixes cannot see one: `<root>/../../.ssh/x`
|
|
34
|
+
* starts with the root and is above it, and `../../.ssh/x` is relative and is above it too. Both were shown in a
|
|
35
|
+
* shared report until this was written.
|
|
36
|
+
*
|
|
37
|
+
* A segment, never a substring: `..config` and `a..b` are names, not a climb.
|
|
38
|
+
*/
|
|
39
|
+
function climbs(value) {
|
|
40
|
+
return value.split(SEPARATOR).includes('..');
|
|
41
|
+
}
|
|
42
|
+
function escapeForPattern(value) {
|
|
43
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, String.raw `\$&`);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* How a path is shown: relative to the project root where it lies under one, and exactly as written where it
|
|
47
|
+
* does not (`specs/2026-09-14-path-display-and-share.md` R1-R3).
|
|
48
|
+
*
|
|
49
|
+
* Two things this buys beyond brevity. A reader of a monorepo sees `apps/web/.env` and `packages/widget/.env`
|
|
50
|
+
* as two files rather than two identical labels. And the part that names the account, the machine and the
|
|
51
|
+
* employer is not the finding, so it stops being repeated on every line of the report.
|
|
52
|
+
*/
|
|
53
|
+
export function displayPath(value, root) {
|
|
54
|
+
if (root.kind !== 'known')
|
|
55
|
+
return value;
|
|
56
|
+
const base = baseOf(root);
|
|
57
|
+
if (value === base || (value.length === base.length + 1 && value.startsWith(base)))
|
|
58
|
+
return PROJECT_ROOT_ITSELF;
|
|
59
|
+
if (!value.startsWith(base) || !SEPARATOR.test(value.charAt(base.length)))
|
|
60
|
+
return value;
|
|
61
|
+
return value.slice(base.length + 1);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Whether a path can be said to lie inside the project - the question the shared view asks before it decides
|
|
65
|
+
* to drop a location.
|
|
66
|
+
*
|
|
67
|
+
* **Corrected 2026-09-14 after a review.** This used to be "the display path is unchanged, so it is outside",
|
|
68
|
+
* and that is false for every path a transcript records relative to the working directory: a `grep` line or a
|
|
69
|
+
* shell argument carries `apps/web/.env`, which no root prefix matches, so the shared view replaced the common
|
|
70
|
+
* case with a category and merged unrelated files into one node.
|
|
71
|
+
*/
|
|
72
|
+
export function insideProject(value, root) {
|
|
73
|
+
// A path that climbs names something the root does not contain, whichever end it starts from. It is the one
|
|
74
|
+
// case where "recorded relative to the working directory" does not mean "inside the project".
|
|
75
|
+
if (climbs(value))
|
|
76
|
+
return false;
|
|
77
|
+
if (!isAbsolute(value))
|
|
78
|
+
return true;
|
|
79
|
+
if (root.kind !== 'known')
|
|
80
|
+
return false;
|
|
81
|
+
return displayPath(value, root) !== value;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The same transformation for a path that sits **inside** free text - a delegation description, a label. Without
|
|
85
|
+
* it the absolute form would simply move out of a path field and into a sentence, which is the failure L010
|
|
86
|
+
* names: a rule applied to one position rather than to the thing itself.
|
|
87
|
+
*
|
|
88
|
+
* The root is replaced only where it **is** the start of a path. `/a/proj` sits inside `/backup/a/proj/x` as
|
|
89
|
+
* text and names nothing there; replacing it produced `/backupx`, a file that exists nowhere. It is also
|
|
90
|
+
* replaced when it stands alone, and to the same `.` that a path field would show - one directory cannot be
|
|
91
|
+
* inside the project in one place and above it in another.
|
|
92
|
+
*/
|
|
93
|
+
export function displayPathsIn(text, root) {
|
|
94
|
+
if (root.kind !== 'known')
|
|
95
|
+
return text;
|
|
96
|
+
const base = escapeForPattern(baseOf(root));
|
|
97
|
+
const pattern = new RegExp(`(?<!${PATH_CHARACTER})${base}(?:[\\\\/]|(?!${PATH_CHARACTER}))`, 'g');
|
|
98
|
+
return text.replace(pattern, (match) => (SEPARATOR.test(match.slice(-1)) ? '' : PROJECT_ROOT_ITSELF));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* An absolute path, conservatively: something that says where it starts - the root, a home directory or a
|
|
102
|
+
* drive - and has at least two segments. Used **only** in the shared view, and only on free text.
|
|
103
|
+
*
|
|
104
|
+
* The leading boundary is load-bearing. Without it the rule matched inside a path that had **already** been made
|
|
105
|
+
* relative - `apps/web/.env` contains `/web/.env` - and a correct display path was destroyed by the step meant
|
|
106
|
+
* to protect it.
|
|
107
|
+
*
|
|
108
|
+
* This is pattern-matching prose, which this project distrusts on principle - so it is pointed in the safe
|
|
109
|
+
* direction. Over-matching costs a reader some detail in a report they chose to share; under-matching puts an
|
|
110
|
+
* account name on a slide. A URL is over-matched on purpose rather than carved out with a second rule.
|
|
111
|
+
*/
|
|
112
|
+
const ABSOLUTE_PATH = /(?<![\w.~@+-])(?:[A-Za-z]:[\\/]|~|\/)[^\s"'`,;:()<>|{}[\]]*[\\/][^\s"'`,;:()<>|{}[\]]+/g;
|
|
113
|
+
/** Free text as a shared report may carry it: nothing above the project root survives, in any position. */
|
|
114
|
+
export function withoutAbsolutePaths(text) {
|
|
115
|
+
return text.replace(ABSOLUTE_PATH, OUTSIDE_THE_PROJECT);
|
|
116
|
+
}
|
|
117
|
+
/** What a machine-issued identifier becomes in a shared report. */
|
|
118
|
+
export const IDENTIFIER_NOT_SHOWN = 'id not shown';
|
|
119
|
+
/**
|
|
120
|
+
* A UUID: how a session, a message or a request is named on the machine that recorded it. Used only in the
|
|
121
|
+
* shared view, on text that is shown - the same place and the same safe direction as `ABSOLUTE_PATH`.
|
|
122
|
+
*/
|
|
123
|
+
const UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
|
|
124
|
+
/**
|
|
125
|
+
* Text as a shared report may carry it: no absolute path and no machine-issued identifier, in any position.
|
|
126
|
+
*
|
|
127
|
+
* **Added 2026-09-14 after running `start --share` on a real project.** Identifiers were already replaced where the
|
|
128
|
+
* report emits them, and that was not enough: a session's own transcript can quote another session's records, and
|
|
129
|
+
* a quoted `uuid` travels inside whatever text carried it.
|
|
130
|
+
*/
|
|
131
|
+
export function forSharing(text) {
|
|
132
|
+
return withoutAbsolutePaths(text).replace(UUID, IDENTIFIER_NOT_SHOWN);
|
|
133
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Each session recognised by the titles of the AI that wrote it (`2026-09-27-what-codex-wrote.md` X28, step 5). */
|
|
2
|
+
export class ProviderTitles {
|
|
3
|
+
#titles;
|
|
4
|
+
constructor(titles) {
|
|
5
|
+
this.#titles = titles;
|
|
6
|
+
}
|
|
7
|
+
recognise(session) {
|
|
8
|
+
return this.#titles[session.provider].recognise(session);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Class C of spec §5.4: entropy alone. It **redacts and never reports** — a high-entropy string is not
|
|
3
|
+
* evidence of anything, and lesson L001 measured what happens without that rule: in one session, 385 `toolu_`
|
|
4
|
+
* identifiers and 1264 UUIDs, every one of them above this threshold. An unguarded class C would have produced
|
|
5
|
+
* over 1600 findings, none of them real.
|
|
6
|
+
*
|
|
7
|
+
* The exclusion list is therefore not an optimisation; it is the only thing that makes the class usable.
|
|
8
|
+
*/
|
|
9
|
+
const THRESHOLD_BITS = 4.0;
|
|
10
|
+
const MINIMUM_LENGTH = 20;
|
|
11
|
+
const EXCLUDED = [
|
|
12
|
+
// The tool's own identifiers - the single most important exclusion on the list (L001).
|
|
13
|
+
/^toolu_[A-Za-z0-9]{20,}$/,
|
|
14
|
+
/^msg_[A-Za-z0-9]{10,}$/,
|
|
15
|
+
/^(agent-)?a[0-9a-f]{16}$/,
|
|
16
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
|
17
|
+
/^[0-9a-f]{40}$/i,
|
|
18
|
+
/^[0-9a-f]{64}$/i,
|
|
19
|
+
/^sha(256|512)-[A-Za-z0-9+/=]+$/,
|
|
20
|
+
/^data:[\w/+.-]+;base64,/,
|
|
21
|
+
/^[\w.-]+\.(ts|js|mjs|cjs|css|map|json)$/i,
|
|
22
|
+
/^https?:\/\//i,
|
|
23
|
+
/^[~.]{0,2}\//,
|
|
24
|
+
// A path is not a secret by shape, and a long one is random-looking enough to cross the threshold: a
|
|
25
|
+
// path like `apps/web/.env.development` was once replaced by a pseudonym, which turned the finding into
|
|
26
|
+
// gibberish. A slash-separated run of ordinary path characters is a path. The cost is a base64 blob that
|
|
27
|
+
// happens to contain a slash and no padding, which class A and class B both still see.
|
|
28
|
+
/^[\w.@+-]+(\/[\w.@+-]+)+$/,
|
|
29
|
+
];
|
|
30
|
+
export function shannonBits(text) {
|
|
31
|
+
const counts = new Map();
|
|
32
|
+
for (const character of text)
|
|
33
|
+
counts.set(character, (counts.get(character) ?? 0) + 1);
|
|
34
|
+
return [...counts.values()].reduce((bits, count) => {
|
|
35
|
+
const probability = count / text.length;
|
|
36
|
+
return bits - probability * Math.log2(probability);
|
|
37
|
+
}, 0);
|
|
38
|
+
}
|
|
39
|
+
/** High entropy, long enough, and none of the shapes this tool produces by the thousand. */
|
|
40
|
+
export function looksRandom(token) {
|
|
41
|
+
if (token.length < MINIMUM_LENGTH)
|
|
42
|
+
return false;
|
|
43
|
+
if (EXCLUDED.some((pattern) => pattern.test(token)))
|
|
44
|
+
return false;
|
|
45
|
+
return shannonBits(token) >= THRESHOLD_BITS;
|
|
46
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { displayPath, displayPathsIn, insideProject, forSharing, OUTSIDE_THE_PROJECT, } from '../project-root.js';
|
|
3
|
+
import { looksRandom } from './entropy.js';
|
|
4
|
+
import { brand } from './redacted.js';
|
|
5
|
+
import { CLASS_A, RULESET_VERSION } from './secret-patterns.js';
|
|
6
|
+
import { ValueTrace } from './value-trace.js';
|
|
7
|
+
import { isConfigName, isExcludedValue, isSensitiveName, unquote } from './value-shapes.js';
|
|
8
|
+
const RANK = { knownFormat: 1, urlPassword: 2, sensitiveName: 3, entropy: 4 };
|
|
9
|
+
/** Step 1: a known format. It **ignores every exclusion** - a real `ghp_…` in a `NEXT_PUBLIC_` name is the case
|
|
10
|
+
* we most want to see. */
|
|
11
|
+
function classAMatches(text) {
|
|
12
|
+
return CLASS_A.flatMap(({ name, pattern }) => [...text.matchAll(pattern)].map((match) => {
|
|
13
|
+
// A pattern may keep a prefix - `:_authToken=` stays, the token after it does not.
|
|
14
|
+
const prefix = typeof match[1] === 'string' ? match[1] : '';
|
|
15
|
+
return { name, rank: RANK.knownFormat, start: match.index + prefix.length, end: match.index + match[0].length };
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Step 2: only the password of a URL, so `postgres://app:[redacted]@db/main` stays readable as a finding.
|
|
20
|
+
*
|
|
21
|
+
* The scheme is bounded, and the bound is the whole point. Written as `[\w+.-]*`, the run before `://` was retried
|
|
22
|
+
* from every position of a line, and a transcript's lines are long - measured at about 31 KB each on real sessions.
|
|
23
|
+
* That made one line quadratic: a CPU profile of a 65 MB session put **3.7 of its 4.4 seconds inside this one
|
|
24
|
+
* pattern**, which is most of what `report`, `check`, `start` and the `Stop` hook were spending per run. Bounding the
|
|
25
|
+
* scheme to what a scheme can be - 32 characters, against `postgres`, `mongodb+srv`, `jdbc` - took the same text from
|
|
26
|
+
* 113 ms to 1 ms, with the same matches. The password itself stays unbounded: a long one is rare, it is reached only
|
|
27
|
+
* after a scheme and a host have already matched, and refusing to redact it would be the wrong way to save time.
|
|
28
|
+
*
|
|
29
|
+
* The lookbehind is the same argument once more. A bounded scheme is still tried from every letter of a run, which
|
|
30
|
+
* on 65 MB of text was another 1.3 seconds; a scheme cannot begin in the middle of a *letter* run, so those
|
|
31
|
+
* positions are refused in one character each. It must be letters only, not `\w` - a scheme glued to a *digit*
|
|
32
|
+
* (a timestamp, a line number) still begins right there, and excluding digits too silently dropped that password.
|
|
33
|
+
* Measured on the same line: 6 ms to none at all, and the same matches for `postgres://`, `mongodb+srv://`, a
|
|
34
|
+
* scheme after `=`, and one welded to a word before it.
|
|
35
|
+
*/
|
|
36
|
+
const URL_PASSWORD = /(?<![A-Za-z])([a-z][\w+.-]{0,31}:\/\/[^\s:/@]+:)([^\s@]+)(?=@)/gi;
|
|
37
|
+
function urlPasswords(text) {
|
|
38
|
+
return [...text.matchAll(URL_PASSWORD)].map((match) => ({
|
|
39
|
+
name: 'url-password',
|
|
40
|
+
rank: RANK.urlPassword,
|
|
41
|
+
start: match.index + (match[1] ?? '').length,
|
|
42
|
+
end: match.index + match[0].length,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Steps 3 and 4: a value beside a sensitive name, once the value-shape exclusions have had their say. The key
|
|
47
|
+
* name is never touched - it is the finding (§5.4).
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* `NAME=value`, `"name": "value"`, `name: value`.
|
|
51
|
+
*
|
|
52
|
+
* The name must start at a boundary and hold no dots, which two failures taught: without the boundary,
|
|
53
|
+
* `apps/web/.env.development:12:KEY=value` read `env.development` as the name and swallowed the rest of the
|
|
54
|
+
* line as its value, and without the optional quotes a JSON key was never seen at all.
|
|
55
|
+
*/
|
|
56
|
+
const KEY_VALUE = /(^|[\s,;:{[(]["']?)([A-Za-z_][A-Za-z0-9_-]*)(["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/g;
|
|
57
|
+
/** What a replacement looks like. A value that is already one is not a value: re-reading it mangles the text. */
|
|
58
|
+
const PSEUDONYM = '[redacted:';
|
|
59
|
+
/**
|
|
60
|
+
* C0 and C1 control characters, and DEL - everything but the tab and the newline a text legitimately carries.
|
|
61
|
+
*
|
|
62
|
+
* A transcript is written by the very party the report is about, and a report is read in a terminal. Left in, an
|
|
63
|
+
* escape sequence of the transcript's choosing reaches that terminal and rewrites what the reader is shown: a
|
|
64
|
+
* `\u001b[2K\u001b[1G` in an agent's own prose overwrites the verdict line above it. `hooks/hook-output.ts` keeps
|
|
65
|
+
* the same guard over the one other channel that reaches a terminal, and for the same reason.
|
|
66
|
+
*
|
|
67
|
+
* A space, not nothing, so a sequence never welds the words on either side of it into one.
|
|
68
|
+
*/
|
|
69
|
+
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
70
|
+
function withoutControls(text) {
|
|
71
|
+
return text.replace(CONTROL, ' ');
|
|
72
|
+
}
|
|
73
|
+
function keyContext(text) {
|
|
74
|
+
return [...text.matchAll(KEY_VALUE)].flatMap((match) => {
|
|
75
|
+
const [, prefix = '', name = '', separator = '', value = ''] = match;
|
|
76
|
+
if (value.startsWith(PSEUDONYM))
|
|
77
|
+
return [];
|
|
78
|
+
if (!isSensitiveName(name) || isExcludedValue(value))
|
|
79
|
+
return [];
|
|
80
|
+
// The quotes stay in the text; only what they hold is replaced.
|
|
81
|
+
const bare = unquote(value);
|
|
82
|
+
const start = match.index + prefix.length + name.length + separator.length + (value.length === bare.length ? 0 : 1);
|
|
83
|
+
return [{ name: 'value beside a sensitive name', key: name, rank: RANK.sensitiveName, start, end: start + bare.length }];
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Step 5: entropy. Redacts, never reports, and only after its own exclusions (lesson L001). The token may end
|
|
88
|
+
* in base64 padding but may not contain `=` inside it: a token that swallows an `=` swallows a whole
|
|
89
|
+
* `KEY=value` and reports the pair as one anonymous blob.
|
|
90
|
+
*/
|
|
91
|
+
const TOKEN = /[A-Za-z0-9_+/~.-]{20,}={0,2}/g;
|
|
92
|
+
function randomTokens(text) {
|
|
93
|
+
return [...text.matchAll(TOKEN)]
|
|
94
|
+
.filter((match) => looksRandom(match[0]))
|
|
95
|
+
.map((match) => ({
|
|
96
|
+
name: 'high-entropy value',
|
|
97
|
+
rank: RANK.entropy,
|
|
98
|
+
start: match.index,
|
|
99
|
+
end: match.index + match[0].length,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The boundary. Everything that reaches the report model passes through here, on the way **in** - never on the
|
|
104
|
+
* way out to a file, because the next renderer would bypass that (spec §5.4, mechanics rule 5).
|
|
105
|
+
*
|
|
106
|
+
* Four doors, each narrow and each a decision at its call site:
|
|
107
|
+
*
|
|
108
|
+
* - `scan` — free text that may hold anything.
|
|
109
|
+
* - `content` — text that came out of a protected resource: redacted **wholesale**, key names kept.
|
|
110
|
+
* - `path` — a path, shown relative to the project root where it lies under one; `--share` is next.
|
|
111
|
+
* - `term` — a tool name, a status, a pattern: vocabulary, not content.
|
|
112
|
+
*
|
|
113
|
+
* There is no general "trust me" door. A value never appears, a fragment of one never appears (rule 4: a prefix
|
|
114
|
+
* is leakage too), and the same value always gets the same pseudonym so a reader can see it is the same one
|
|
115
|
+
* without being told which (rule 3).
|
|
116
|
+
*/
|
|
117
|
+
/**
|
|
118
|
+
* The two key shapes of a protected resource's lines: `KEY=value` and `"key": value`. The first group is the key with
|
|
119
|
+
* what joins it to the value, the second the value. `content` replaces the second and `keysIn` reads the first, from
|
|
120
|
+
* these same expressions. `[ \t]` rather than `\s`: `\s` crosses a newline, so a key with an empty value swallowed
|
|
121
|
+
* the line below it and deleted the next key's name - the one thing this promises to keep.
|
|
122
|
+
*/
|
|
123
|
+
const ASSIGNED = /([A-Za-z_][A-Za-z0-9_]*[ \t]*=[ \t]*)("[^"]*"|'[^']*'|\S+)/g;
|
|
124
|
+
const QUOTED_KEY = /("[A-Za-z_][A-Za-z0-9_-]*"[ \t]*:[ \t]*)("[^"]*"|[^,\s]+)/g;
|
|
125
|
+
/**
|
|
126
|
+
* Of matches that overlap, the one of highest precedence - a known format over a password in a URL over a value beside a
|
|
127
|
+
* sensitive name over an entropy guess - and then by position; the result in the order the matches start. The one
|
|
128
|
+
* resolution both the redaction and a count of keys use, so the two can never name the same value differently.
|
|
129
|
+
*/
|
|
130
|
+
function strongest(found) {
|
|
131
|
+
const chosen = [];
|
|
132
|
+
for (const match of [...found].sort((a, b) => a.rank - b.rank || a.start - b.start || b.end - a.end)) {
|
|
133
|
+
if (chosen.some((taken) => match.start < taken.end && taken.start < match.end))
|
|
134
|
+
continue;
|
|
135
|
+
chosen.push(match);
|
|
136
|
+
}
|
|
137
|
+
return chosen.sort((a, b) => a.start - b.start);
|
|
138
|
+
}
|
|
139
|
+
export class Redactor {
|
|
140
|
+
#salt;
|
|
141
|
+
#projectRoot;
|
|
142
|
+
/** The shared view of `specs/2026-09-14-path-display-and-share.md`: nothing above the project root leaves the tool. */
|
|
143
|
+
#share;
|
|
144
|
+
/** Hash of a value -> the pseudonym already given to it. The value itself is never kept (rule 6). */
|
|
145
|
+
#pseudonyms = new Map();
|
|
146
|
+
#counts = new Map();
|
|
147
|
+
/** Occurrences, not distinct values: one key seen fifty times is fifty replacements in the output. */
|
|
148
|
+
#replacements = 0;
|
|
149
|
+
#protectedContents = 0;
|
|
150
|
+
constructor(salt, projectRoot = { kind: 'absent' }, share = false) {
|
|
151
|
+
this.#salt = salt;
|
|
152
|
+
this.#projectRoot = projectRoot;
|
|
153
|
+
this.#share = share;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Free text: class A matches are replaced, everything else is left as written - except that a path inside it
|
|
157
|
+
* is shown the way a path in a path field is (R8). A description that quotes an absolute path is still a
|
|
158
|
+
* description carrying an absolute path.
|
|
159
|
+
*/
|
|
160
|
+
scan(text) {
|
|
161
|
+
const shown = displayPathsIn(withoutControls(text), this.#projectRoot);
|
|
162
|
+
return brand(this.#scanText(this.#share ? forSharing(shown) : shown));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Text that came out of a protected resource. The scanner recognises the minority of secrets, so nothing here
|
|
166
|
+
* is trusted to be safe, and the decision is made **line by line**: deciding once for the whole text let a
|
|
167
|
+
* single `FOO=bar` anywhere hand the rest of the file through untouched.
|
|
168
|
+
*
|
|
169
|
+
* A line shaped `KEY=value` or `"key": "value"` keeps its key - the key name is the finding - and loses its
|
|
170
|
+
* value. A line of any other shape goes whole. A text where no line has a key shape is replaced by its size,
|
|
171
|
+
* which is all that can be said about it.
|
|
172
|
+
*/
|
|
173
|
+
content(text) {
|
|
174
|
+
this.#protectedContents += 1;
|
|
175
|
+
const lines = withoutControls(text).split('\n');
|
|
176
|
+
const redacted = lines.map((line) => this.#contentLine(line));
|
|
177
|
+
if (redacted.every((line, index) => line === lines[index] || line.startsWith('[redacted: line'))) {
|
|
178
|
+
return brand(`[redacted: content of a protected resource — ${lines.length} lines, ${Buffer.byteLength(text)} bytes]`);
|
|
179
|
+
}
|
|
180
|
+
return brand(redacted.join('\n'));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* A path is the finding, not the secret, and §7.3 shows it as written. It is still scanned: these come from
|
|
184
|
+
* splitting untrusted result text, so a value that happens to match a path pattern arrives through this door
|
|
185
|
+
* as readily as through any other.
|
|
186
|
+
*/
|
|
187
|
+
path(value) {
|
|
188
|
+
const cleaned = withoutControls(value);
|
|
189
|
+
// The shared view drops a location only for a path that says where it starts and is not under the root.
|
|
190
|
+
// A path recorded relative to the working directory is already inside the project, and asking "did the
|
|
191
|
+
// string change" instead of "is it inside" turned every one of those into a category.
|
|
192
|
+
if (this.#share && !insideProject(cleaned, this.#projectRoot))
|
|
193
|
+
return brand(OUTSIDE_THE_PROJECT);
|
|
194
|
+
// What reaches this door as a path is not always one. A command, or a fragment of quoted JSON, can be taken
|
|
195
|
+
// for a candidate and still end in the file a policy protects; it does not start with a root, so it counts as
|
|
196
|
+
// inside the project - and an absolute path or an identifier further along it was shown whole. Found by
|
|
197
|
+
// running `start --share` on a real project. A path field is no weaker than a sentence (R8).
|
|
198
|
+
const shown = displayPath(cleaned, this.#projectRoot);
|
|
199
|
+
return brand(this.#scanText(this.#share ? forSharing(shown) : shown));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A trace of values read from protected resources (`specs/2026-09-15-what-came-back.md` R3), salted with this run's salt so
|
|
203
|
+
* that its digests mean nothing outside this one report (rule 6). The values are hashed here and not kept.
|
|
204
|
+
*/
|
|
205
|
+
trace(values) {
|
|
206
|
+
return new ValueTrace(values, (run) => createHash('sha256').update(`${this.#salt}:trace:${run}`).digest('hex'));
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Vocabulary: a tool name, an outcome, a policy pattern. No value passes here - but a tool name is still read
|
|
210
|
+
* out of the transcript, so the control characters go, as they do through every other door.
|
|
211
|
+
*/
|
|
212
|
+
term(value) {
|
|
213
|
+
return brand(withoutControls(value));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Which known key formats appear in a piece of text, by class name and never by value. This is the `S-shape`
|
|
217
|
+
* source of §5.4: a secret can be recognised in output that touched no protected path at all, and saying
|
|
218
|
+
* "a key of this kind came back here" is a fact worth reporting without quoting a character of it.
|
|
219
|
+
*/
|
|
220
|
+
classesIn(text) {
|
|
221
|
+
// Entropy never issues a finding on its own (§5.4): it redacts, and it says nothing. Only what was
|
|
222
|
+
// recognised - a known format, or a value beside a sensitive name - is reported.
|
|
223
|
+
// The report's threshold, not redaction's: a class B match is worth stating only when the key reads as one a
|
|
224
|
+
// configuration would carry. Measured on a real session: 139 matches, of which 129 stood beside names from the
|
|
225
|
+
// repository's own code - `sessionId`, `key`, `secretShapes` - and 10 beside names like `WEBHOOK_SECRET`.
|
|
226
|
+
return [
|
|
227
|
+
...new Set([...classAMatches(text), ...urlPasswords(text), ...keyContext(text)]
|
|
228
|
+
.filter((match) => match.key === undefined || isConfigName(match.key))
|
|
229
|
+
.map((match) => match.name)),
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The names on the keyed lines of a protected resource - `STRIPE_SECRET_KEY` of `STRIPE_SECRET_KEY=sk_…` - and never
|
|
234
|
+
* their values. They are the same keys `content` keeps, read by the same two expressions, so a name given here is one
|
|
235
|
+
* the redacted text already shows. Each is scanned like any other text, in case a name is itself a key.
|
|
236
|
+
*/
|
|
237
|
+
keysIn(text) {
|
|
238
|
+
const names = new Set();
|
|
239
|
+
for (const line of withoutControls(text).split('\n')) {
|
|
240
|
+
if (line.trim() === '' || line.trimStart().startsWith('#'))
|
|
241
|
+
continue;
|
|
242
|
+
for (const match of line.matchAll(ASSIGNED))
|
|
243
|
+
names.add((match[1] ?? '').replace(/[ \t]*=[ \t]*$/, ''));
|
|
244
|
+
for (const match of line.matchAll(QUOTED_KEY))
|
|
245
|
+
names.add((match[1] ?? '').replace(/^"|"[ \t]*:[ \t]*$/g, ''));
|
|
246
|
+
}
|
|
247
|
+
return [...names].filter((name) => name !== '').map((name) => brand(this.#scanText(name)));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The names of the lines whose value is a key - `STRIPE_SECRET_KEY` of `STRIPE_SECRET_KEY=sk_…`, not `NODE_ENV` of
|
|
251
|
+
* `NODE_ENV=development` - each with what its value was recognised as (`specs/2026-09-23-the-report-page.md` M2a):
|
|
252
|
+
* the same resolution `content` redacts by, a known format first and a random value last. A random value is counted
|
|
253
|
+
* here, where §5.4 lets it issue no finding, because it says only which of a protected file's lines hold a key, of a
|
|
254
|
+
* file already on the list. Names only, in the order first read, once each; never a value.
|
|
255
|
+
*/
|
|
256
|
+
keyedIn(text) {
|
|
257
|
+
const found = new Map();
|
|
258
|
+
for (const line of withoutControls(text).split('\n')) {
|
|
259
|
+
if (line.trim() === '' || line.trimStart().startsWith('#'))
|
|
260
|
+
continue;
|
|
261
|
+
const secrets = strongest([...classAMatches(line), ...urlPasswords(line), ...keyContext(line), ...randomTokens(line)]
|
|
262
|
+
.filter((match) => match.key === undefined || isConfigName(match.key)));
|
|
263
|
+
if (secrets.length === 0)
|
|
264
|
+
continue;
|
|
265
|
+
for (const [expression, clean] of [[ASSIGNED, /[ \t]*=[ \t]*$/g], [QUOTED_KEY, /^"|"[ \t]*:[ \t]*$/g]]) {
|
|
266
|
+
for (const match of line.matchAll(expression)) {
|
|
267
|
+
const start = match.index + (match[1] ?? '').length;
|
|
268
|
+
const end = start + (match[2] ?? '').length;
|
|
269
|
+
const secret = secrets.find((each) => each.start < end && start < each.end);
|
|
270
|
+
const name = (match[1] ?? '').replace(clean, '');
|
|
271
|
+
if (secret !== undefined && name !== '' && !found.has(name))
|
|
272
|
+
found.set(name, secret.name);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return [...found].map(([name, key]) => ({ name: brand(this.#scanText(name)), key }));
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The key format of each value in a text, one class per value: where two classes match the same value - a Stripe key
|
|
280
|
+
* standing beside `STRIPE_SECRET_KEY=` - the one the redaction itself would choose wins, the known format. What
|
|
281
|
+
* `classesIn` lists is every class seen; this is what each key is (`specs/2026-09-23-the-report-page.md` M1), so a
|
|
282
|
+
* file of Stripe keys is not also said to hold a key nobody can name. In the order the values appear, once each.
|
|
283
|
+
*/
|
|
284
|
+
keyClassesIn(text) {
|
|
285
|
+
const found = [...classAMatches(text), ...urlPasswords(text), ...keyContext(text)]
|
|
286
|
+
.filter((match) => match.key === undefined || isConfigName(match.key));
|
|
287
|
+
return [...new Set(strongest(found).map((match) => match.name))];
|
|
288
|
+
}
|
|
289
|
+
/** What the report header has to say about redaction (§5.4, versioning). */
|
|
290
|
+
summary() {
|
|
291
|
+
return {
|
|
292
|
+
rulesetVersion: RULESET_VERSION,
|
|
293
|
+
redactions: this.#replacements,
|
|
294
|
+
distinctValues: this.#pseudonyms.size,
|
|
295
|
+
protectedContents: this.#protectedContents,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* One pass over the text, not one pass per pattern. Replacing pattern by pattern lets a later pattern match
|
|
300
|
+
* inside an earlier replacement - `:_authToken=` swallowed the `[redacted: …]` a token pattern had just
|
|
301
|
+
* written, and left half of it in the output. Matches are collected first, overlaps resolved by taking the
|
|
302
|
+
* one that starts earliest and reaches furthest, and the text is rebuilt once.
|
|
303
|
+
*/
|
|
304
|
+
/** Blank lines and comments carry nothing; `=` and `:` shapes keep their key; everything else goes whole. */
|
|
305
|
+
#contentLine(line) {
|
|
306
|
+
if (line.trim() === '' || line.trimStart().startsWith('#'))
|
|
307
|
+
return line;
|
|
308
|
+
const keyed = line
|
|
309
|
+
.replace(ASSIGNED, (_, key, value) => `${key}${this.#pseudonym('value from a protected resource', value)}`)
|
|
310
|
+
.replace(QUOTED_KEY, (_, key, value) => `${key}${this.#pseudonym('value from a protected resource', value)}`);
|
|
311
|
+
return keyed === line ? `[redacted: line of a protected resource — ${line.length} characters]` : this.#scanText(keyed);
|
|
312
|
+
}
|
|
313
|
+
#scanText(text) {
|
|
314
|
+
const chosen = strongest([...classAMatches(text), ...urlPasswords(text), ...keyContext(text), ...randomTokens(text)]);
|
|
315
|
+
let output = '';
|
|
316
|
+
let taken = 0;
|
|
317
|
+
for (const match of chosen) {
|
|
318
|
+
output += text.slice(taken, match.start) + this.#pseudonym(match.name, text.slice(match.start, match.end));
|
|
319
|
+
taken = match.end;
|
|
320
|
+
}
|
|
321
|
+
return output + text.slice(taken);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The same value twice is the same pseudonym, so "this key is also in that other file" can be read off the
|
|
325
|
+
* report. A per-run salt makes the hash useless outside this one report (rule 6).
|
|
326
|
+
*/
|
|
327
|
+
#pseudonym(className, value) {
|
|
328
|
+
this.#replacements += 1;
|
|
329
|
+
const digest = createHash('sha256').update(`${this.#salt}:${value}`).digest('hex');
|
|
330
|
+
const known = this.#pseudonyms.get(digest);
|
|
331
|
+
if (known !== undefined)
|
|
332
|
+
return known;
|
|
333
|
+
const index = (this.#counts.get(className) ?? 0) + 1;
|
|
334
|
+
this.#counts.set(className, index);
|
|
335
|
+
const pseudonym = `[redacted: ${className} #${index}]`;
|
|
336
|
+
this.#pseudonyms.set(digest, pseudonym);
|
|
337
|
+
return pseudonym;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// v1: class A alone, with content from a protected resource redacted wholesale.
|
|
2
|
+
// v2 (2026-09-14): classes B and C, with the exclusion lists of §5.4 that make them usable at all.
|
|
3
|
+
export const RULESET_VERSION = 2;
|
|
4
|
+
export const CLASS_A = [
|
|
5
|
+
{ name: 'anthropic-key', pattern: /sk-ant-[A-Za-z0-9_-]{32,}/g },
|
|
6
|
+
{ name: 'openai-key', pattern: /sk-(?:proj-)?[A-Za-z0-9]{20,}/g },
|
|
7
|
+
{ name: 'github-token', pattern: /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}/g },
|
|
8
|
+
{ name: 'gitlab-token', pattern: /glpat-[A-Za-z0-9_-]{10,}/g },
|
|
9
|
+
{ name: 'slack-token', pattern: /xox[baprs]-[A-Za-z0-9-]{10,}|xapp-[A-Za-z0-9-]{10,}/g },
|
|
10
|
+
{ name: 'aws-access-key-id', pattern: /(?:AKIA|ASIA)[A-Z0-9]{16}/g },
|
|
11
|
+
{ name: 'google-key', pattern: /AIza[A-Za-z0-9_-]{35}|ya29\.[A-Za-z0-9_-]{10,}/g },
|
|
12
|
+
{ name: 'stripe-key', pattern: /(?:sk|rk)_live_[A-Za-z0-9]{10,}|whsec_[A-Za-z0-9]{10,}/g },
|
|
13
|
+
{ name: 'supabase-key', pattern: /sbp_[A-Za-z0-9]{10,}/g },
|
|
14
|
+
{ name: 'npm-token', pattern: /npm_[A-Za-z0-9]{36}/g },
|
|
15
|
+
{ name: 'digitalocean-token', pattern: /do[op]_v1_[a-f0-9]{30,}/g },
|
|
16
|
+
{ name: 'sendgrid-key', pattern: /SG\.[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{15,}/g },
|
|
17
|
+
{ name: 'private-key', pattern: /-----BEGIN[A-Z ]*PRIVATE KEY-----[\s\S]*?-----END[A-Z ]*PRIVATE KEY-----/g },
|
|
18
|
+
// Three base64url segments, the first of which starts a JSON object: `SUPABASE_SERVICE_ROLE_KEY` is one.
|
|
19
|
+
{ name: 'jwt', pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g },
|
|
20
|
+
// The GitHub token that sat in git history since 2024 was an `.npmrc` auth token.
|
|
21
|
+
{ name: 'npmrc-auth-token', pattern: /(:_authToken=)\S+/g },
|
|
22
|
+
];
|