@pugi/cli 0.1.0-beta.9 → 0.1.0-beta.90
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/CHANGELOG.md +132 -0
- package/LICENSE +1 -1
- package/assets/pugi-prozr2-mascot.ansi +9 -0
- package/bin/run.js +33 -1
- package/dist/commands/deploy.js +40 -40
- package/dist/commands/flatten.js +191 -0
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +42 -27
- package/dist/commands/smoke.js +133 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/agents/adaptive-router.js +330 -0
- package/dist/core/agents/query-decomposer.js +297 -0
- package/dist/core/agents/registry.js +3 -3
- package/dist/core/approvals/shortcut-resolver.js +98 -0
- package/dist/core/artifact-chain/dispatcher.js +148 -0
- package/dist/core/artifact-chain/exporter.js +164 -0
- package/dist/core/artifact-chain/state.js +243 -0
- package/dist/core/artifact-chain/steps.js +169 -0
- package/dist/core/ask-user/question.js +92 -0
- package/dist/core/audit/audit-trail.js +275 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/auth/env-provider.js +238 -0
- package/dist/core/auto-open-browser.js +4 -4
- package/dist/core/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/bash/redirect.js +281 -0
- package/dist/core/bash-classifier.js +436 -40
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/checkpoints/shadow-git.js +670 -0
- package/dist/core/citations/parser.js +109 -0
- package/dist/core/classifier/yolo-classifier.js +88 -0
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +208 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/anvil-fanout.js +25 -25
- package/dist/core/consensus/diff-capture.js +121 -12
- package/dist/core/consensus/rubric.js +21 -21
- package/dist/core/context/builder.js +6 -6
- package/dist/core/context/compaction-events.js +8 -8
- package/dist/core/context/compaction.js +31 -31
- package/dist/core/context/index.js +15 -8
- package/dist/core/context/invariants.js +51 -51
- package/dist/core/context/markdown-loader.js +28 -10
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/context/pugiignore.js +41 -41
- package/dist/core/context/repo-skeleton.js +37 -37
- package/dist/core/context/tool-eviction.js +55 -0
- package/dist/core/context/watcher.js +32 -32
- package/dist/core/context/working-set.js +23 -23
- package/dist/core/coordinator/agent-tools.js +77 -0
- package/dist/core/coordinator/agent-toolset.js +65 -0
- package/dist/core/coordinator/fsm.js +73 -0
- package/dist/core/coordinator/mode-fsm.js +70 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -0
- package/dist/core/credentials.js +13 -13
- package/dist/core/cron/scheduler.js +138 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +93 -0
- package/dist/core/diagnostics/probes/bare-mode.js +42 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/engine-live.js +46 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/hooks.js +118 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/pugi-md.js +89 -0
- package/dist/core/diagnostics/probes/sandbox.js +40 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -0
- package/dist/core/edits/apply-patch-layer-e.js +189 -0
- package/dist/core/edits/dispatch.js +333 -7
- package/dist/core/edits/format-detector.js +260 -0
- package/dist/core/edits/format-matrix.js +26 -0
- package/dist/core/edits/fuzzy-ladder.js +650 -0
- package/dist/core/edits/index.js +5 -1
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-a-apply.js +15 -15
- package/dist/core/edits/layer-a-fuzzy-apply.js +198 -0
- package/dist/core/edits/layer-b-apply.js +9 -9
- package/dist/core/edits/layer-c-apply.js +6 -6
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/marker-parser.js +12 -12
- package/dist/core/edits/security-gate.js +27 -27
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +29 -29
- package/dist/core/engine/anvil-client.js +214 -26
- package/dist/core/engine/auto-compact.js +179 -0
- package/dist/core/engine/budgets.js +186 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/index.js +1 -1
- package/dist/core/engine/intensity.js +158 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +1295 -227
- package/dist/core/engine/prompts.js +129 -19
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +1731 -59
- package/dist/core/evaluation/golden-dataset.js +293 -0
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/file-cache.js +113 -1
- package/dist/core/flatten/flatten-repo.js +439 -0
- package/dist/core/format/osc8-link.js +28 -0
- package/dist/core/hook-chains.js +392 -0
- package/dist/core/hooks/citation-verify-hook.js +138 -0
- package/dist/core/hooks/citation-verify.js +112 -0
- package/dist/core/hooks/events.js +46 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +216 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/hooks/worktree-events.js +158 -0
- package/dist/core/image/renderer.js +71 -0
- package/dist/core/init/detector.js +582 -0
- package/dist/core/init/template-renderer.js +242 -0
- package/dist/core/jobs/registry.js +18 -18
- package/dist/core/ledger/results-tsv.js +142 -0
- package/dist/core/log-discipline/stdout-redirect.js +51 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/client.js +551 -41
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/lsp/server-detect.js +173 -0
- package/dist/core/lsp/symbol-cache.js +162 -0
- package/dist/core/lsp/symbol-tools.js +664 -0
- package/dist/core/mcp/client.js +97 -28
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/orchestrator-tools.js +662 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +39 -17
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/mcp/trust.js +10 -10
- package/dist/core/memory/dual-write.js +416 -0
- package/dist/core/memory/passive-extract.js +130 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/memory/secret-scanner.js +304 -0
- package/dist/core/memory-sync/queue.js +170 -0
- package/dist/core/metrics/extract.js +113 -0
- package/dist/core/modes/roo-modes.js +68 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/path-security.js +287 -5
- package/dist/core/permission.js +82 -22
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/bash-parser.js +371 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/constrained-edit.js +91 -0
- package/dist/core/permissions/gate.js +278 -0
- package/dist/core/permissions/index.js +20 -0
- package/dist/core/permissions/mode.js +174 -0
- package/dist/core/permissions/network-egress.js +137 -0
- package/dist/core/permissions/state.js +241 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/plan-mode/ui-state.js +51 -0
- package/dist/core/plans/plan-artifact.js +721 -0
- package/dist/core/policy-limits/etag-store.js +122 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/session-review.js +557 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/prompt-cache/client-cache.js +99 -0
- package/dist/core/prompts/assembly.js +29 -0
- package/dist/core/prompts/registry.js +364 -0
- package/dist/core/pugi-md/cc-compat-rules.js +735 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/python/uv-installer.js +270 -0
- package/dist/core/python/uv-resolver.js +83 -0
- package/dist/core/rate-limit/narrator.js +146 -0
- package/dist/core/recipes/cli-types.js +20 -0
- package/dist/core/recipes/loader.js +103 -0
- package/dist/core/recipes/runner.js +345 -0
- package/dist/core/recipes/schema.js +587 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/ask.js +37 -37
- package/dist/core/repl/cancellation.js +26 -26
- package/dist/core/repl/cap-warning.js +4 -4
- package/dist/core/repl/clipboard-read.js +11 -11
- package/dist/core/repl/dispatch-fsm.js +12 -12
- package/dist/core/repl/history-search.js +15 -15
- package/dist/core/repl/history.js +28 -18
- package/dist/core/repl/kill-ring.js +5 -5
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/privacy-banner.js +22 -22
- package/dist/core/repl/session.js +2148 -217
- package/dist/core/repl/slash-commands.js +501 -41
- package/dist/core/repl/store/index.js +1 -1
- package/dist/core/repl/store/jsonl-log.js +22 -22
- package/dist/core/repl/store/lockfile.js +10 -10
- package/dist/core/repl/store/session-store.js +136 -107
- package/dist/core/repl/store/types.js +15 -15
- package/dist/core/repl/store/uuid-v7.js +12 -12
- package/dist/core/repl/workspace-context.js +43 -21
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/page-rank.js +105 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/retry-budget/retry-cap.js +74 -0
- package/dist/core/routing/lead-worker.js +43 -0
- package/dist/core/routing/pre-flight-estimator.js +108 -0
- package/dist/core/runs/run-tree.js +103 -0
- package/dist/core/security/injection-scanner.js +367 -0
- package/dist/core/security/output-filter.js +418 -0
- package/dist/core/session/env-file.js +105 -0
- package/dist/core/session/section-budgets.js +140 -0
- package/dist/core/session.js +92 -0
- package/dist/core/settings.js +324 -5
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/skills/defaults.js +30 -30
- package/dist/core/skills/loader.js +22 -22
- package/dist/core/skills/sources.js +27 -27
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/core/statusline.js +99 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +132 -43
- package/dist/core/subagents/index.js +19 -6
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/tool-schema/compressor.js +89 -0
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/core/trust.js +2 -2
- package/dist/core/tui/thinking-block.js +64 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/core/watch-markers/marker-watcher.js +133 -0
- package/dist/core/worktree/include-parser.js +249 -0
- package/dist/core/worktree-manager/cleanup.js +123 -0
- package/dist/core/worktree-manager/manager.js +303 -0
- package/dist/index.js +36 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +4185 -549
- package/dist/runtime/commands/agents.js +31 -31
- package/dist/runtime/commands/budget.js +5 -5
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/chain.js +489 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/compact.js +297 -0
- package/dist/runtime/commands/config.js +73 -39
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +27 -4
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/doctor.js +579 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/hooks.js +187 -0
- package/dist/runtime/commands/init.js +254 -0
- package/dist/runtime/commands/lsp.js +200 -38
- package/dist/runtime/commands/mcp.js +879 -0
- package/dist/runtime/commands/memory.js +582 -0
- package/dist/runtime/commands/model.js +237 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/patch.js +12 -12
- package/dist/runtime/commands/permissions.js +112 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/prd-check.js +285 -0
- package/dist/runtime/commands/privacy.js +17 -17
- package/dist/runtime/commands/recipe.js +325 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/review-consensus.js +68 -53
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/roster.js +14 -14
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/skills.js +31 -31
- package/dist/runtime/commands/status.js +186 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/undo.js +54 -22
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/commands/worktree.js +8 -8
- package/dist/runtime/commands/worktrees.js +155 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +22 -22
- package/dist/runtime/sigint-guard.js +272 -0
- package/dist/runtime/update-check.js +28 -28
- package/dist/runtime/version.js +65 -0
- package/dist/runtime/worktree-bootstrap.js +579 -0
- package/dist/skills/bundled/batch.js +617 -0
- package/dist/skills/bundled/index.js +45 -0
- package/dist/skills/bundled/loop.js +358 -0
- package/dist/skills/bundled/remember.js +383 -0
- package/dist/skills/bundled/simplify.js +289 -0
- package/dist/skills/bundled/skillify.js +373 -0
- package/dist/skills/bundled/stuck.js +558 -0
- package/dist/skills/bundled/verify.js +439 -0
- package/dist/testing/vcr.js +486 -0
- package/dist/tools/agent-tool.js +229 -0
- package/dist/tools/apply-patch.js +89 -28
- package/dist/tools/ask-user-question.js +337 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/bash.js +624 -46
- package/dist/tools/brief.js +224 -0
- package/dist/tools/enter-worktree.js +250 -0
- package/dist/tools/exit-worktree.js +147 -0
- package/dist/tools/file-tools.js +161 -44
- package/dist/tools/lsp-tools.js +377 -1
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/powershell.js +268 -0
- package/dist/tools/registry.js +86 -4
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/sleep.js +99 -0
- package/dist/tools/synthetic-output.js +133 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tools/verify-plan-execution.js +295 -0
- package/dist/tools/web-fetch-injection-scanner.js +207 -0
- package/dist/tools/web-fetch.js +195 -10
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +11 -1
- package/dist/tui/ask-modal.js +14 -14
- package/dist/tui/ask-user-question-chips.js +315 -0
- package/dist/tui/ask-user-question-prompt.js +203 -0
- package/dist/tui/compact-banner.js +81 -0
- package/dist/tui/conversation-pane.js +85 -11
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/device-flow.js +2 -2
- package/dist/tui/doctor-table.js +46 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/input-box.js +247 -32
- package/dist/tui/login-picker.js +3 -3
- package/dist/tui/markdown-render.js +6 -6
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/permissions-picker.js +86 -0
- package/dist/tui/render.js +36 -1
- package/dist/tui/repl-render.js +176 -25
- package/dist/tui/repl-splash-art.js +16 -16
- package/dist/tui/repl-splash-mascot.js +48 -24
- package/dist/tui/repl-splash.js +22 -22
- package/dist/tui/repl.js +125 -45
- package/dist/tui/slash-palette.js +6 -6
- package/dist/tui/splash.js +2 -2
- package/dist/tui/status-bar.js +109 -31
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/thinking-spinner.js +123 -0
- package/dist/tui/tool-stream-pane.js +53 -4
- package/dist/tui/update-banner.js +27 -2
- package/dist/tui/vim-input.js +267 -0
- package/dist/tui/welcome-banner.js +107 -0
- package/dist/tui/welcome-data.js +293 -0
- package/dist/tui/workspace-context.js +2 -2
- package/package.json +31 -16
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +12 -0
- package/test/scenarios/identity.scenario.txt +12 -0
- package/test/scenarios/persona-handoff.scenario.txt +12 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-wide JSONL audit trail .
|
|
3
|
+
*
|
|
4
|
+
* Pugi already records every tool_call / tool_result in two places:
|
|
5
|
+
*
|
|
6
|
+
* 1. The global per-workspace log at `<workspace>/.pugi/events.jsonl`
|
|
7
|
+
* (audit-replay source of truth; see `core/session.ts`).
|
|
8
|
+
* 2. The per-session mirror at
|
|
9
|
+
* `<workspace>/.pugi/sessions/<sessionId>/events.jsonl`
|
|
10
|
+
* (operator-friendly per-run copy, see `native-pugi.ts`).
|
|
11
|
+
*
|
|
12
|
+
* Both live under the workspace directory and disappear when the
|
|
13
|
+
* operator wipes the workspace or runs many ephemeral sandboxes.
|
|
14
|
+
* What's missing is a TENANT-wide structured audit log: a single
|
|
15
|
+
* append-only NDJSON stream per (tenant, workspace) pair that the
|
|
16
|
+
* operator (or a SOC pipeline) can tail across every session over
|
|
17
|
+
* the lifetime of the host.
|
|
18
|
+
*
|
|
19
|
+
* Spec :
|
|
20
|
+
*
|
|
21
|
+
* - Path: `~/.pugi/audit/<tenant>/<workspace-slug>-<hash>.jsonl`
|
|
22
|
+
* - One JSON line per event with shared shape:
|
|
23
|
+
* `{ ts, tenant, workspace, workspaceHash, event, sessionId, data }`
|
|
24
|
+
* - Events covered: `tool_call`, `tool_result`, `dispatch_start`,
|
|
25
|
+
* `dispatch_end`, `permission_denied`, `auto_compact`,
|
|
26
|
+
* `budget_exhausted`.
|
|
27
|
+
* - Append-only — no rotation logic. Operators wire `logrotate`
|
|
28
|
+
* themselves if they want size caps.
|
|
29
|
+
* - Opt-out: `PUGI_AUDIT_TRAIL_DISABLE=1`.
|
|
30
|
+
* - Failures NEVER throw. Audit MUST NOT break a dispatch.
|
|
31
|
+
* - Tenant fallback: when `PUGI_API_KEY` is unset, tenant is `local`.
|
|
32
|
+
*
|
|
33
|
+
* Why duplicate the per-session log on disk:
|
|
34
|
+
*
|
|
35
|
+
* The per-session mirror clusters by `sessionId` (one dir per run).
|
|
36
|
+
* To answer "what did this tenant DO across every session this week
|
|
37
|
+
* from this workspace" an operator otherwise has to glob hundreds of
|
|
38
|
+
* session dirs and merge by timestamp. The audit trail flattens that
|
|
39
|
+
* into one tail-able stream per (tenant, workspace) — same shape an
|
|
40
|
+
* ops pipeline would expect from a hosted log surface.
|
|
41
|
+
*/
|
|
42
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
43
|
+
import { createHash } from 'node:crypto';
|
|
44
|
+
import { homedir } from 'node:os';
|
|
45
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
46
|
+
import { collectStrings, scanForInjection, summarizeFindings, } from '../security/injection-scanner.js';
|
|
47
|
+
/**
|
|
48
|
+
* Opt-out env var. Mirrors the convention every other Pugi feature uses
|
|
49
|
+
* (`PUGI_BARE`, `PUGI_AGENTMEMORY_RECALL_ENABLED=false`, etc.).
|
|
50
|
+
* Operators set this when they pipe the CLI through a sandbox that
|
|
51
|
+
* already captures audit upstream and they want to skip the duplicate.
|
|
52
|
+
*/
|
|
53
|
+
export const PUGI_AUDIT_TRAIL_DISABLE_VAR = 'PUGI_AUDIT_TRAIL_DISABLE';
|
|
54
|
+
/**
|
|
55
|
+
* Tenant fallback used when the operator has not exported
|
|
56
|
+
* `PUGI_API_KEY`. The audit trail still flows — it just lives under
|
|
57
|
+
* `~/.pugi/audit/local/...` so a single-user workstation gets a useful
|
|
58
|
+
* forensic log without needing API-key plumbing.
|
|
59
|
+
*/
|
|
60
|
+
export const LOCAL_TENANT_FALLBACK = 'local';
|
|
61
|
+
/**
|
|
62
|
+
* Sanitize the workspace basename to a safe filesystem slug:
|
|
63
|
+
* lowercase a-z + 0-9 + `-`. Anything else collapses to `-`. We avoid
|
|
64
|
+
* the empty case (root workspace) by falling back to `workspace`.
|
|
65
|
+
*
|
|
66
|
+
* Why not a hash here too: the hash is appended separately so two
|
|
67
|
+
* workspaces with the same basename (e.g. two clones of the same repo
|
|
68
|
+
* sitting in different parent dirs) get distinct files. The slug is
|
|
69
|
+
* the human-readable half operators eyeball at `ls ~/.pugi/audit/...`.
|
|
70
|
+
*/
|
|
71
|
+
export function sanitizeWorkspaceSlug(workspaceRoot) {
|
|
72
|
+
const base = basename(resolve(workspaceRoot));
|
|
73
|
+
const sanitized = base
|
|
74
|
+
.toLowerCase()
|
|
75
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
76
|
+
.replace(/-+/g, '-')
|
|
77
|
+
.replace(/^-|-$/g, '');
|
|
78
|
+
return sanitized.length > 0 ? sanitized : 'workspace';
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Stable, anonymous workspace handle. We use the FIRST 8 hex of
|
|
82
|
+
* sha256(workspaceRoot). 8 hex = 32 bits = ~4 billion buckets, more
|
|
83
|
+
* than enough to disambiguate `~/code/foo` from `~/other/foo` on the
|
|
84
|
+
* same host without leaking the absolute path through the file name.
|
|
85
|
+
*
|
|
86
|
+
* The hash is over the RESOLVED path so symlink trickery cannot point
|
|
87
|
+
* two different audit streams at the same file by accident.
|
|
88
|
+
*/
|
|
89
|
+
export function computeWorkspaceHash(workspaceRoot) {
|
|
90
|
+
return createHash('sha256')
|
|
91
|
+
.update(resolve(workspaceRoot))
|
|
92
|
+
.digest('hex')
|
|
93
|
+
.slice(0, 8);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Derive the tenant slug from `PUGI_API_KEY`. We hash the key (sha256,
|
|
97
|
+
* 12 hex prefix) rather than emitting the raw key — the audit trail is
|
|
98
|
+
* a plaintext file on the local FS and the tenant slug shows up in
|
|
99
|
+
* every path under `~/.pugi/audit/`. A truncated hash is enough to
|
|
100
|
+
* cluster every (tenant, workspace) over time without leaking the key
|
|
101
|
+
* if the operator accidentally `tar`s their `~/.pugi` for support.
|
|
102
|
+
*
|
|
103
|
+
* The hash is purely a CLI-local clustering key — the runtime backend
|
|
104
|
+
* has its own (different) tenant identifier and never sees this.
|
|
105
|
+
*/
|
|
106
|
+
export function resolveTenant(env = process.env) {
|
|
107
|
+
const key = env.PUGI_API_KEY?.trim();
|
|
108
|
+
if (!key)
|
|
109
|
+
return LOCAL_TENANT_FALLBACK;
|
|
110
|
+
// 12 hex = 48 bits — enough disambiguation for any realistic per-host
|
|
111
|
+
// tenant cardinality; still short enough for operators to eyeball at
|
|
112
|
+
// `ls ~/.pugi/audit/`.
|
|
113
|
+
return createHash('sha256').update(key).digest('hex').slice(0, 12);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the audit file path for a given (tenant, workspace) pair.
|
|
117
|
+
* Pure path arithmetic — the caller is responsible for `mkdir -p`
|
|
118
|
+
* before append (handled inside `writeAuditEvent`).
|
|
119
|
+
*/
|
|
120
|
+
export function resolveAuditPath(workspaceRoot, tenant, home = homedir()) {
|
|
121
|
+
const slug = sanitizeWorkspaceSlug(workspaceRoot);
|
|
122
|
+
const hash = computeWorkspaceHash(workspaceRoot);
|
|
123
|
+
return join(home, '.pugi', 'audit', tenant, `${slug}-${hash}.jsonl`);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Predicate: is the audit trail disabled via env opt-out?
|
|
127
|
+
*
|
|
128
|
+
* Accept `1`, `true`, `yes` (case-insensitive) as positive; anything
|
|
129
|
+
* else — including `0`, `false`, `''`, and the var being absent — keeps
|
|
130
|
+
* the trail enabled. Mirrors the convention used in `bare-mode/` and
|
|
131
|
+
* elsewhere in the CLI.
|
|
132
|
+
*/
|
|
133
|
+
export function isAuditDisabled(env = process.env) {
|
|
134
|
+
const raw = env[PUGI_AUDIT_TRAIL_DISABLE_VAR]?.trim().toLowerCase();
|
|
135
|
+
if (!raw)
|
|
136
|
+
return false;
|
|
137
|
+
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Append a single audit event to the per-tenant per-workspace NDJSON
|
|
141
|
+
* trail. Never throws — failures (FS unwritable, opt-out, malformed
|
|
142
|
+
* input) are silently swallowed so a misconfigured audit surface
|
|
143
|
+
* cannot break a dispatch. The engine adapter's existing per-session
|
|
144
|
+
* mirror remains intact as a redundant copy.
|
|
145
|
+
*
|
|
146
|
+
* Append-only: every call writes exactly one line. No rotation, no
|
|
147
|
+
* truncation. Operators wire `logrotate` if they want size caps.
|
|
148
|
+
*
|
|
149
|
+
* macOS hardening: we `mkdir -p` the parent dir on every call (cheap
|
|
150
|
+
* in practice — Node short-circuits when the dir exists) so a manual
|
|
151
|
+
* `rm -rf ~/.pugi/audit/<tenant>/` between runs does not turn the next
|
|
152
|
+
* append into ENOENT. The mode is `0o700` for the tenant dir and
|
|
153
|
+
* `0o600` for the JSONL file so curious users on a shared host cannot
|
|
154
|
+
* read another tenant's trail.
|
|
155
|
+
*/
|
|
156
|
+
export function writeAuditEvent(input) {
|
|
157
|
+
const env = input.env ?? process.env;
|
|
158
|
+
if (isAuditDisabled(env))
|
|
159
|
+
return;
|
|
160
|
+
try {
|
|
161
|
+
const tenant = (input.tenant?.trim() || resolveTenant(env)) || LOCAL_TENANT_FALLBACK;
|
|
162
|
+
const home = input.home ?? homedir();
|
|
163
|
+
const path = resolveAuditPath(input.workspaceRoot, tenant, home);
|
|
164
|
+
const now = input.now ? input.now() : new Date().toISOString();
|
|
165
|
+
const envelope = {
|
|
166
|
+
ts: now,
|
|
167
|
+
tenant,
|
|
168
|
+
workspace: sanitizeWorkspaceSlug(input.workspaceRoot),
|
|
169
|
+
workspaceHash: computeWorkspaceHash(input.workspaceRoot),
|
|
170
|
+
event: input.event,
|
|
171
|
+
sessionId: input.sessionId,
|
|
172
|
+
data: input.data,
|
|
173
|
+
};
|
|
174
|
+
try {
|
|
175
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// mkdir failure is silent — the appendFileSync below will surface
|
|
179
|
+
// the real error and the outer catch swallows it. We still try
|
|
180
|
+
// the write so EEXIST on the dir (the only real path here) does
|
|
181
|
+
// not block the append.
|
|
182
|
+
}
|
|
183
|
+
appendFileSync(path, `${JSON.stringify(envelope)}\n`, {
|
|
184
|
+
encoding: 'utf8',
|
|
185
|
+
mode: 0o600,
|
|
186
|
+
});
|
|
187
|
+
// Injection scan (ported an external utility,
|
|
188
|
+
// Apache-2.0). Wrap the OUTBOUND `data` payload through the
|
|
189
|
+
// scanner. Findings emit a SECOND audit line of type
|
|
190
|
+
// `injection_detected` so an operator (or SOC pipeline) sees a
|
|
191
|
+
// structured, append-only record without losing the original
|
|
192
|
+
// event. Never blocks the write — hard-block requires a separate
|
|
193
|
+
// CEO-signed PR.
|
|
194
|
+
//
|
|
195
|
+
// Recursion guard: the `injection_detected` event itself carries
|
|
196
|
+
// matched substrings (intentional — they are the evidence). We
|
|
197
|
+
// skip scanning it to avoid an infinite loop of self-detections.
|
|
198
|
+
if (input.event !== 'injection_detected') {
|
|
199
|
+
const findings = scanAuditPayload(input.data);
|
|
200
|
+
if (findings.length > 0) {
|
|
201
|
+
emitInjectionDetected({
|
|
202
|
+
findings,
|
|
203
|
+
triggeringEvent: input.event,
|
|
204
|
+
sessionId: input.sessionId,
|
|
205
|
+
workspaceRoot: input.workspaceRoot,
|
|
206
|
+
tenant: input.tenant,
|
|
207
|
+
env: input.env,
|
|
208
|
+
home: input.home,
|
|
209
|
+
now: input.now,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Audit failures must NEVER break a dispatch. The session log + the
|
|
216
|
+
// per-session mirror under `<workspace>/.pugi/` remain as redundant
|
|
217
|
+
// surfaces. A future telemetry pass can surface the failure count
|
|
218
|
+
// via the doctor probe; for now silent no-op is the contract.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Fold the audit `data` payload into a single string and scan it for
|
|
223
|
+
* prompt-injection / invisible-unicode / secret markers. Returns the
|
|
224
|
+
* empty array on clean payloads.
|
|
225
|
+
*
|
|
226
|
+
* Exported for the spec — the scanner module owns the algorithm, this
|
|
227
|
+
* helper owns the payload-walking glue.
|
|
228
|
+
*/
|
|
229
|
+
export function scanAuditPayload(data) {
|
|
230
|
+
// Fold every string anywhere in the payload (keys included) into a
|
|
231
|
+
// single buffer separated by NULs. NUL keeps regex anchors honest
|
|
232
|
+
// (no accidental cross-field match for a `^system:` pattern) without
|
|
233
|
+
// adding bytes that themselves could become a pattern.
|
|
234
|
+
const fragments = collectStrings(data);
|
|
235
|
+
if (fragments.length === 0)
|
|
236
|
+
return [];
|
|
237
|
+
const joined = fragments.join('\0');
|
|
238
|
+
return scanForInjection(joined);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Build the `injection_detected` envelope payload and recurse into
|
|
242
|
+
* `writeAuditEvent` to append it. The recursion is bounded — the
|
|
243
|
+
* recursion guard in `writeAuditEvent` short-circuits on the
|
|
244
|
+
* `injection_detected` event so we never re-scan ourselves.
|
|
245
|
+
*/
|
|
246
|
+
function emitInjectionDetected(input) {
|
|
247
|
+
const summary = summarizeFindings(input.findings);
|
|
248
|
+
// Cap the findings array in the audit line so a payload with
|
|
249
|
+
// hundreds of invisible-unicode hits does not bloat the JSONL row.
|
|
250
|
+
// The summary still carries `total` so operators see the real count.
|
|
251
|
+
const MAX_FINDINGS_PER_EVENT = 32;
|
|
252
|
+
const truncated = input.findings.length > MAX_FINDINGS_PER_EVENT;
|
|
253
|
+
const capped = truncated
|
|
254
|
+
? input.findings.slice(0, MAX_FINDINGS_PER_EVENT)
|
|
255
|
+
: [...input.findings];
|
|
256
|
+
writeAuditEvent({
|
|
257
|
+
event: 'injection_detected',
|
|
258
|
+
sessionId: input.sessionId,
|
|
259
|
+
workspaceRoot: input.workspaceRoot,
|
|
260
|
+
tenant: input.tenant,
|
|
261
|
+
env: input.env,
|
|
262
|
+
home: input.home,
|
|
263
|
+
now: input.now,
|
|
264
|
+
data: {
|
|
265
|
+
triggeringEvent: input.triggeringEvent,
|
|
266
|
+
summary,
|
|
267
|
+
findings: capped,
|
|
268
|
+
truncated,
|
|
269
|
+
// External attribution is recorded inline so a SOC pipeline
|
|
270
|
+
// grepping for the upstream project name lands here.
|
|
271
|
+
detector: 'external-injection-patterns',
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
//# sourceMappingURL=audit-trail.js.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UX — `ensureAuthenticated` helper.
|
|
3
|
+
*
|
|
4
|
+
* Auto-login pre-flight for every Pugi command that needs an Anvil
|
|
5
|
+
* credential. Before this helper landed, cold-start without a stored
|
|
6
|
+
* credential surfaced a generic "Login required" message and the
|
|
7
|
+
* operator had к run `pugi login` separately, which broke the muscle-
|
|
8
|
+
* memory of "open terminal, type the command, see the answer".
|
|
9
|
+
*
|
|
10
|
+
* The helper exposes a single contract: `ensureAuthenticated(opts)`.
|
|
11
|
+
* On a happy path (credential resolves) it returns the credential.
|
|
12
|
+
* On a cold-start it either:
|
|
13
|
+
*
|
|
14
|
+
* - launches the device-flow login inline (interactive TTY only,
|
|
15
|
+
* `--no-login` not set), waits for completion, then re-resolves
|
|
16
|
+
* the credential and returns it. The surrounding command continues
|
|
17
|
+
* transparently;
|
|
18
|
+
* - returns `{ status: 'missing' }` with a reason describing why no
|
|
19
|
+
* auto-login was attempted (non-interactive, opted-out, or login
|
|
20
|
+
* aborted by user). The caller bails with a clean message.
|
|
21
|
+
*
|
|
22
|
+
* Cross-command parity: the helper is wired into every command that
|
|
23
|
+
* needs auth (engine commands `code`/`fix`/`build`/`explain`/`plan`,
|
|
24
|
+
* plus `sync`, `chain new`, `smoke`, `review`, `deploy`, ...). The
|
|
25
|
+
* previous patchwork of `resolveActiveCredential() ?? throw` /
|
|
26
|
+
* `if (!config) writeOutput unauthenticated` calls now all funnel
|
|
27
|
+
* through here so future auth changes are one-edit.
|
|
28
|
+
*
|
|
29
|
+
* Session cache: the helper caches the resolved credential per-process.
|
|
30
|
+
* A second command in the same process never re-launches login even if
|
|
31
|
+
* the operator deletes credentials.json mid-run (that is a footgun, not
|
|
32
|
+
* a supported use case — the cached credential is still valid because
|
|
33
|
+
* the auth token in memory has not been revoked).
|
|
34
|
+
*
|
|
35
|
+
* Framework-free: the actual login call is injected via the `login`
|
|
36
|
+
* callback. The CLI passes a closure that calls
|
|
37
|
+
* `performDeviceFlowLogin` (or the interactive picker for token /
|
|
38
|
+
* env). The spec passes a fake that flips an in-memory env var so
|
|
39
|
+
* subsequent `resolveActiveCredential` calls see a credential.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Process-local cache of resolved credentials. Keyed by `apiUrl` so a
|
|
43
|
+
* future `pugi accounts switch` invocation does not return stale data
|
|
44
|
+
* (different apiUrl → cache miss). Cache is additive-only.
|
|
45
|
+
*/
|
|
46
|
+
const credentialCache = new Map();
|
|
47
|
+
/**
|
|
48
|
+
* Reset the cache. Exported for spec teardown — production callers
|
|
49
|
+
* never need this.
|
|
50
|
+
*/
|
|
51
|
+
export function resetAuthenticatedCache() {
|
|
52
|
+
credentialCache.clear();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Auth pre-flight. Returns the resolved credential or a structured
|
|
56
|
+
* `missing` envelope. The cached path skips the `resolve()` callback
|
|
57
|
+
* entirely — useful when `resolveActiveCredential` is expensive
|
|
58
|
+
* (filesystem read of ~/.pugi/credentials.json + Zod parse).
|
|
59
|
+
*
|
|
60
|
+
* Headless contract: even on a TTY, when `headless === true` the
|
|
61
|
+
* helper bails with `non_interactive`. Reason: a browser-popup login
|
|
62
|
+
* in the middle of an automated stdin → engine → stdout loop would
|
|
63
|
+
* silently freeze the run.
|
|
64
|
+
*/
|
|
65
|
+
export async function ensureAuthenticated(opts) {
|
|
66
|
+
// Resolve once. Cache by the resolved apiUrl so a subsequent call
|
|
67
|
+
// after `pugi accounts switch` produces a fresh resolution.
|
|
68
|
+
const initial = opts.resolve();
|
|
69
|
+
if (initial) {
|
|
70
|
+
credentialCache.set(initial.apiUrl, initial);
|
|
71
|
+
return { status: 'ready', credential: initial };
|
|
72
|
+
}
|
|
73
|
+
if (opts.skip) {
|
|
74
|
+
return {
|
|
75
|
+
status: 'missing',
|
|
76
|
+
reason: 'disabled',
|
|
77
|
+
detail: 'Authentication skipped (--no-login or PUGI_NO_AUTO_LOGIN). Run `pugi login` to authenticate.',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (opts.headless) {
|
|
81
|
+
return {
|
|
82
|
+
status: 'missing',
|
|
83
|
+
reason: 'non_interactive',
|
|
84
|
+
detail: 'Headless mode cannot launch browser-popup login. Run `pugi login` once with a TTY, then re-run with --headless.',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (!opts.interactive) {
|
|
88
|
+
return {
|
|
89
|
+
status: 'missing',
|
|
90
|
+
reason: 'non_interactive',
|
|
91
|
+
detail: 'No credential found and stdin is not a TTY. Run `pugi login` with a TTY OR set PUGI_API_KEY before invoking Pugi in CI.',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const write = opts.write ?? ((line) => process.stderr.write(line));
|
|
95
|
+
write('No Pugi credential found. Launching login...\n');
|
|
96
|
+
let succeeded;
|
|
97
|
+
try {
|
|
98
|
+
succeeded = await opts.login();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return {
|
|
102
|
+
status: 'missing',
|
|
103
|
+
reason: 'login_failed',
|
|
104
|
+
detail: `Login failed: ${error.message ?? String(error)}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (!succeeded) {
|
|
108
|
+
return {
|
|
109
|
+
status: 'missing',
|
|
110
|
+
reason: 'login_cancelled',
|
|
111
|
+
detail: 'Authentication required to continue. Run `pugi login` when ready.',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// Re-resolve. If a successful login was reported but no credential
|
|
115
|
+
// landed on disk, surface that as `login_failed` rather than a
|
|
116
|
+
// silent miss — would otherwise produce a confusing "you said it
|
|
117
|
+
// worked but I still see nothing" loop.
|
|
118
|
+
const resolved = opts.resolve();
|
|
119
|
+
if (!resolved) {
|
|
120
|
+
return {
|
|
121
|
+
status: 'missing',
|
|
122
|
+
reason: 'login_failed',
|
|
123
|
+
detail: 'Login reported success but no credential persisted. Check `pugi whoami`.',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
credentialCache.set(resolved.apiUrl, resolved);
|
|
127
|
+
return { status: 'ready', credential: resolved };
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=ensure-authenticated.js.map
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi login --provider env` — env-var auth path ().
|
|
3
|
+
*
|
|
4
|
+
* the upstream tool, peer CLI, and gh CLI all ship a way to authenticate via
|
|
5
|
+
* an environment variable so CI / container / scripted contexts can
|
|
6
|
+
* skip the device flow entirely. This module backs that path:
|
|
7
|
+
*
|
|
8
|
+
* 1. Resolve the candidate token (explicit `--key` flag beats
|
|
9
|
+
* `PUGI_API_KEY` env — same precedence as `gh auth login --token`).
|
|
10
|
+
* 2. Run a cheap local format check so an obviously malformed key
|
|
11
|
+
* (empty, whitespace, suspiciously short) fails fast WITHOUT
|
|
12
|
+
* shipping it to the server (no observability leak into the
|
|
13
|
+
* Anvil access log).
|
|
14
|
+
* 3. Call `GET /api/pugi/health` with `Authorization: Bearer <key>`
|
|
15
|
+
* so an expired / revoked / typo'd token surfaces immediately
|
|
16
|
+
* and the credential file never lands on disk for a dead key.
|
|
17
|
+
* 4. Map response to typed outcome the CLI dispatcher can render.
|
|
18
|
+
*
|
|
19
|
+
* The module is intentionally pure — fetch + reading env are injected,
|
|
20
|
+
* the writer is a separate concern. The CLI dispatcher composes
|
|
21
|
+
* `resolveEnvCandidateToken` + `assertTokenFormat` + `validateTokenAgainstHealth`
|
|
22
|
+
* and then writes the credential via `storeApiKey` on success.
|
|
23
|
+
*
|
|
24
|
+
* Failure modes are explicit so the dispatcher can pick the user-facing
|
|
25
|
+
* remediation string without re-parsing strings:
|
|
26
|
+
*
|
|
27
|
+
* - `missing` → no token in env or --key, halt with hint
|
|
28
|
+
* - `invalid-format` → token failed local format check, halt
|
|
29
|
+
* - `unauthorized` → server rejected the token (401 / 403)
|
|
30
|
+
* - `network-error` → fetch threw (DNS, refused, TLS)
|
|
31
|
+
* - `server-error` → server returned 5xx (transient — operator
|
|
32
|
+
* may want to retry once)
|
|
33
|
+
* - `unexpected-status`→ anything else non-2xx (treat as failure)
|
|
34
|
+
*
|
|
35
|
+
* NEVER log the raw token. Memory hits
|
|
36
|
+
* `feedback_no_claude_attribution_anywhere_hard_rule` plus the
|
|
37
|
+
* CSO bearer-leak sweep apply here. Use `maskApiKey` from
|
|
38
|
+
* `core/credentials.ts` when the dispatcher needs to surface the key
|
|
39
|
+
* to the operator.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* The minimum length below which we refuse to even ship the token to
|
|
43
|
+
* the server. Pugi-issued PATs are 48+ chars (`pugi_<32 base32>`), JWTs
|
|
44
|
+
* issued by the device flow are ~250 chars, legacy `sk-*` PATs we
|
|
45
|
+
* accept for compatibility are 32+. 16 is well below all three real
|
|
46
|
+
* shapes so it only catches obvious paste mistakes.
|
|
47
|
+
*/
|
|
48
|
+
export const MIN_TOKEN_LENGTH = 16;
|
|
49
|
+
/**
|
|
50
|
+
* The set of prefixes we recognise as plausibly-real Pugi-shaped
|
|
51
|
+
* tokens. Loose by design — the real validator is the server-side
|
|
52
|
+
* health probe. We just want to catch an operator who pasted the
|
|
53
|
+
* wrong string entirely (a username, a URL, a placeholder like
|
|
54
|
+
* "<your-key>") before it reaches the network.
|
|
55
|
+
*
|
|
56
|
+
* Three-segment JWTs are also accepted via the `looksLikeJwt`
|
|
57
|
+
* predicate so device-flow tokens copied out of `~/.pugi/credentials.json`
|
|
58
|
+
* on a different machine work.
|
|
59
|
+
*/
|
|
60
|
+
export const RECOGNISED_TOKEN_PREFIXES = ['pugi_', 'sk_', 'sk-', 'pat_'];
|
|
61
|
+
/**
|
|
62
|
+
* Returns the trimmed candidate token, or `null` when neither path
|
|
63
|
+
* produced one. Precedence: explicit flag arg beats env var (matches
|
|
64
|
+
* `gh auth login --with-token`, `aws configure set`, and `pugi config`
|
|
65
|
+
* which all prefer the most-specific operator intent over the ambient
|
|
66
|
+
* env).
|
|
67
|
+
*/
|
|
68
|
+
export function resolveEnvCandidateToken(input) {
|
|
69
|
+
const explicit = input.explicitKey?.trim();
|
|
70
|
+
if (explicit)
|
|
71
|
+
return explicit;
|
|
72
|
+
const env = input.env ?? process.env;
|
|
73
|
+
const fromEnv = env.PUGI_API_KEY?.trim();
|
|
74
|
+
if (fromEnv)
|
|
75
|
+
return fromEnv;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Local-only format check. Returns `null` on accept, a human-readable
|
|
80
|
+
* error string on reject. Deliberately lenient — the server-side
|
|
81
|
+
* health probe is the source of truth. We only catch obvious paste
|
|
82
|
+
* mistakes (empty, whitespace-laden, too short, looks like a URL or
|
|
83
|
+
* a placeholder).
|
|
84
|
+
*/
|
|
85
|
+
export function assertTokenFormat(token) {
|
|
86
|
+
if (!token)
|
|
87
|
+
return 'Token is empty';
|
|
88
|
+
if (/\s/.test(token)) {
|
|
89
|
+
return 'Token contains whitespace — check for shell quoting issues or a stray newline';
|
|
90
|
+
}
|
|
91
|
+
if (token.length < MIN_TOKEN_LENGTH) {
|
|
92
|
+
return `Token too short (${token.length} chars; Pugi tokens are >= ${MIN_TOKEN_LENGTH})`;
|
|
93
|
+
}
|
|
94
|
+
if (token.startsWith('<') && token.endsWith('>')) {
|
|
95
|
+
return 'Token looks like a placeholder (`<your-key>`) — replace with the actual key';
|
|
96
|
+
}
|
|
97
|
+
if (/^https?:\/\//i.test(token)) {
|
|
98
|
+
return 'Token looks like a URL — did you mean --api-url?';
|
|
99
|
+
}
|
|
100
|
+
// Accept either a recognised prefix OR a JWT three-segment shape.
|
|
101
|
+
// Anything else still passes — the server probe will catch genuinely
|
|
102
|
+
// unknown keys. We just want to surface an obvious mistake.
|
|
103
|
+
const hasKnownPrefix = RECOGNISED_TOKEN_PREFIXES.some((p) => token.startsWith(p));
|
|
104
|
+
if (!hasKnownPrefix && !looksLikeJwt(token)) {
|
|
105
|
+
// Soft-fail: warn the operator but proceed. Returning null here
|
|
106
|
+
// would mask the case where the operator pasted something
|
|
107
|
+
// genuinely wrong but the server happens to accept it (impossible
|
|
108
|
+
// for real keys but defence-in-depth). Returning the warning
|
|
109
|
+
// string would block legacy keys. We choose to proceed — the
|
|
110
|
+
// server is the source of truth — and let the CLI dispatcher
|
|
111
|
+
// decide whether to surface a note. Tracked via a separate
|
|
112
|
+
// `warnUnknownPrefix` return on a future revision.
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* JWT three-segment check. Does NOT verify the signature — we just
|
|
119
|
+
* want to recognise the shape so device-flow tokens copied from one
|
|
120
|
+
* machine to another pass the format gate.
|
|
121
|
+
*/
|
|
122
|
+
export function looksLikeJwt(token) {
|
|
123
|
+
const parts = token.split('.');
|
|
124
|
+
if (parts.length !== 3)
|
|
125
|
+
return false;
|
|
126
|
+
return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 0);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Call `GET /api/pugi/health` with the candidate token. Returns a
|
|
130
|
+
* typed outcome that the CLI dispatcher can map directly to an exit
|
|
131
|
+
* code + remediation string.
|
|
132
|
+
*
|
|
133
|
+
* Health endpoint conventions (see apps/admin-api):
|
|
134
|
+
* - 200 → token is valid, account is active
|
|
135
|
+
* - 401 → token unknown / malformed at the server boundary
|
|
136
|
+
* - 403 → token recognised but the account is suspended / paused
|
|
137
|
+
* - 5xx → server-side issue, operator can retry
|
|
138
|
+
* - network throw → DNS, refused, TLS — operator's connectivity issue
|
|
139
|
+
*
|
|
140
|
+
* We do not parse the body — the health endpoint's contract is the
|
|
141
|
+
* status code. Any future field (latency, region, build sha) can be
|
|
142
|
+
* surfaced by a separate `pugi doctor` probe without touching the
|
|
143
|
+
* login path.
|
|
144
|
+
*/
|
|
145
|
+
export async function validateTokenAgainstHealth(input) {
|
|
146
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
147
|
+
const now = input.now ?? Date.now;
|
|
148
|
+
const url = `${stripTrailingSlash(input.apiUrl)}/api/pugi/health`;
|
|
149
|
+
const started = now();
|
|
150
|
+
let response;
|
|
151
|
+
try {
|
|
152
|
+
response = await fetchImpl(url, {
|
|
153
|
+
method: 'GET',
|
|
154
|
+
headers: {
|
|
155
|
+
Authorization: `Bearer ${input.apiKey}`,
|
|
156
|
+
Accept: 'application/json',
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
// DNS failure, ECONNREFUSED, TLS handshake — anything that makes
|
|
162
|
+
// fetch throw before a status code is observable. We deliberately
|
|
163
|
+
// do NOT echo the URL host in the message body if it could leak a
|
|
164
|
+
// self-hosted Anvil hostname into a public CI log; the dispatcher
|
|
165
|
+
// composes the user-facing remediation.
|
|
166
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
167
|
+
return {
|
|
168
|
+
kind: 'network-error',
|
|
169
|
+
message: `Cannot reach ${input.apiUrl}; check your connection`,
|
|
170
|
+
cause,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const latencyMs = now() - started;
|
|
174
|
+
const { status } = response;
|
|
175
|
+
if (status === 200) {
|
|
176
|
+
return { kind: 'ok', latencyMs };
|
|
177
|
+
}
|
|
178
|
+
if (status === 401 || status === 403) {
|
|
179
|
+
return {
|
|
180
|
+
kind: 'unauthorized',
|
|
181
|
+
status,
|
|
182
|
+
message: status === 401
|
|
183
|
+
? 'Token invalid or expired — run `pugi login --provider device` to get a fresh one'
|
|
184
|
+
: 'Token recognised but the account is suspended — check `pugi whoami` on a working machine or contact support',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (status >= 500) {
|
|
188
|
+
return {
|
|
189
|
+
kind: 'server-error',
|
|
190
|
+
status,
|
|
191
|
+
message: `${input.apiUrl} returned ${status}; retry in a moment`,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
kind: 'unexpected-status',
|
|
196
|
+
status,
|
|
197
|
+
message: `Unexpected ${status} from /api/pugi/health; treat as login failure`,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
export async function resolveAndValidateEnvLogin(input) {
|
|
201
|
+
const token = resolveEnvCandidateToken({
|
|
202
|
+
explicitKey: input.explicitKey,
|
|
203
|
+
env: input.env,
|
|
204
|
+
});
|
|
205
|
+
if (!token) {
|
|
206
|
+
return {
|
|
207
|
+
kind: 'missing',
|
|
208
|
+
message: 'pugi login --provider env requires a token. Export PUGI_API_KEY in the current shell or pass --key <value>.',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const formatError = assertTokenFormat(token);
|
|
212
|
+
if (formatError) {
|
|
213
|
+
return {
|
|
214
|
+
kind: 'invalid-format',
|
|
215
|
+
message: `pugi login --provider env: ${formatError}`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (input.skipValidate) {
|
|
219
|
+
// Used by the existing `login-variants.spec.ts` regression suite
|
|
220
|
+
// so the test plane does not require a live network. Production
|
|
221
|
+
// path always validates.
|
|
222
|
+
return { kind: 'ok', token, latencyMs: 0 };
|
|
223
|
+
}
|
|
224
|
+
const probe = await validateTokenAgainstHealth({
|
|
225
|
+
apiUrl: input.apiUrl,
|
|
226
|
+
apiKey: token,
|
|
227
|
+
fetchImpl: input.fetchImpl,
|
|
228
|
+
now: input.now,
|
|
229
|
+
});
|
|
230
|
+
if (probe.kind === 'ok') {
|
|
231
|
+
return { kind: 'ok', token, latencyMs: probe.latencyMs };
|
|
232
|
+
}
|
|
233
|
+
return probe;
|
|
234
|
+
}
|
|
235
|
+
function stripTrailingSlash(url) {
|
|
236
|
+
return url.endsWith('/') ? url.slice(0, -1) : url;
|
|
237
|
+
}
|
|
238
|
+
//# sourceMappingURL=env-provider.js.map
|
|
@@ -18,7 +18,7 @@ export async function autoOpenBrowser(url, deps = {}) {
|
|
|
18
18
|
return { opened: spawnDetached('open', [url]) };
|
|
19
19
|
}
|
|
20
20
|
if (platform === 'win32') {
|
|
21
|
-
// P1-3 (triple-review
|
|
21
|
+
// P1-3 (triple-review): cmd.exe parses `&` as a command
|
|
22
22
|
// separator BEFORE Node hands argv to the child, regardless of
|
|
23
23
|
// `shell: false`. A device-flow URL like
|
|
24
24
|
// `https://app.pugi.io/devices/authorize?user_code=ABC&trace=xyz`
|
|
@@ -67,7 +67,7 @@ function isSafeHttpUrl(candidate) {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
* P1-3 (triple-review
|
|
70
|
+
* P1-3 (triple-review): wrap a URL for PowerShell's
|
|
71
71
|
* `Start-Process` invocation. PowerShell's single-quote string literal
|
|
72
72
|
* does NOT process escape sequences; the only metachar inside is the
|
|
73
73
|
* single quote itself (escaped by doubling). URLs do not contain
|
|
@@ -78,7 +78,7 @@ function quoteForPowerShell(url) {
|
|
|
78
78
|
return `'${url.replace(/'/g, "''")}'`;
|
|
79
79
|
}
|
|
80
80
|
/**
|
|
81
|
-
* P1-3 (triple-review
|
|
81
|
+
* P1-3 (triple-review): wrap a URL for cmd.exe's
|
|
82
82
|
* `start ""` invocation. Double quotes pin the URL as a single token
|
|
83
83
|
* so cmd does NOT split on `&`, `|`, `^`, `<`, `>`. Embedded `"` (rare
|
|
84
84
|
* in URLs but defensible) is escaped via cmd's caret-quote convention:
|
|
@@ -102,7 +102,7 @@ function defaultSpawnDetached(cmd, args) {
|
|
|
102
102
|
shell: false,
|
|
103
103
|
};
|
|
104
104
|
const child = spawn(cmd, args.slice(), options);
|
|
105
|
-
// P3 polish (triple-review
|
|
105
|
+
// P3 polish (triple-review): swallow the async `error`
|
|
106
106
|
// event (ENOENT for a missing binary, EACCES for a sandboxed
|
|
107
107
|
// permission denial). The old `errored` flag was always false at
|
|
108
108
|
// the synchronous `return !errored` point (the `error` event is
|