@nathapp/nax-agent 0.1.0
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 +24 -0
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/coding-tools/coding-tool-bash.d.ts +46 -0
- package/dist/coding-tools/coding-tool-bash.js +44 -0
- package/dist/coding-tools/coding-tool-extras.d.ts +35 -0
- package/dist/coding-tools/coding-tool-extras.js +56 -0
- package/dist/coding-tools/coding-tool-sandbox.d.ts +74 -0
- package/dist/coding-tools/coding-tool-sandbox.js +215 -0
- package/dist/coding-tools/coding-tool-support.d.ts +132 -0
- package/dist/coding-tools/coding-tool-support.js +159 -0
- package/dist/coding-tools/universal-coding-tools.d.ts +11 -0
- package/dist/coding-tools/universal-coding-tools.js +5 -0
- package/dist/command-interceptor/index.d.ts +55 -0
- package/dist/command-interceptor/index.js +60 -0
- package/dist/command-interceptor/shell.d.ts +36 -0
- package/dist/command-interceptor/shell.js +137 -0
- package/dist/command-safety/build.d.ts +19 -0
- package/dist/command-safety/build.js +47 -0
- package/dist/command-safety/guard.d.ts +32 -0
- package/dist/command-safety/guard.js +179 -0
- package/dist/command-safety/identifiers.d.ts +24 -0
- package/dist/command-safety/identifiers.js +20 -0
- package/dist/command-safety/index.d.ts +17 -0
- package/dist/command-safety/index.js +11 -0
- package/dist/command-safety/questions.d.ts +19 -0
- package/dist/command-safety/questions.js +60 -0
- package/dist/command-safety/row.d.ts +2 -0
- package/dist/command-safety/row.js +14 -0
- package/dist/command-safety/rule-scorer.d.ts +9 -0
- package/dist/command-safety/rule-scorer.js +391 -0
- package/dist/command-safety/shadow.d.ts +33 -0
- package/dist/command-safety/shadow.js +188 -0
- package/dist/command-safety/systemone-client.d.ts +16 -0
- package/dist/command-safety/systemone-client.js +92 -0
- package/dist/command-safety/tap.d.ts +37 -0
- package/dist/command-safety/tap.js +77 -0
- package/dist/command-safety/temp-only.d.ts +6 -0
- package/dist/command-safety/temp-only.js +215 -0
- package/dist/command-safety/tmp-write.d.ts +2 -0
- package/dist/command-safety/tmp-write.js +112 -0
- package/dist/command-safety/types.d.ts +185 -0
- package/dist/command-safety/types.js +21 -0
- package/dist/config/bash-approval.d.ts +33 -0
- package/dist/config/bash-approval.js +30 -0
- package/dist/config/catalog-overrides.d.ts +94 -0
- package/dist/config/catalog-overrides.js +7 -0
- package/dist/config/native-agent/index.d.ts +8 -0
- package/dist/config/native-agent/index.js +8 -0
- package/dist/config/schemas-sandbox.d.ts +31 -0
- package/dist/config/schemas-sandbox.js +45 -0
- package/dist/cost/core/index.d.ts +8 -0
- package/dist/cost/core/index.js +7 -0
- package/dist/cost/estimate.d.ts +34 -0
- package/dist/cost/estimate.js +65 -0
- package/dist/cost/model-spec.d.ts +28 -0
- package/dist/cost/model-spec.js +29 -0
- package/dist/cost/standard-types.d.ts +9 -0
- package/dist/cost/standard-types.js +1 -0
- package/dist/cost/usage-math.d.ts +18 -0
- package/dist/cost/usage-math.js +46 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +21 -0
- package/dist/infra/agent-logger.d.ts +22 -0
- package/dist/infra/agent-logger.js +19 -0
- package/dist/infra/credentials-config.d.ts +20 -0
- package/dist/infra/credentials-config.js +15 -0
- package/dist/infra/errors.d.ts +9 -0
- package/dist/infra/errors.js +11 -0
- package/dist/infra/index.d.ts +7 -0
- package/dist/infra/index.js +7 -0
- package/dist/infra/nax-error.d.ts +10 -0
- package/dist/infra/nax-error.js +16 -0
- package/dist/infra/spin-breaker/hash.d.ts +2 -0
- package/dist/infra/spin-breaker/hash.js +5 -0
- package/dist/infra/spin-breaker/index.d.ts +120 -0
- package/dist/infra/spin-breaker/index.js +340 -0
- package/dist/internal/agent-output-env.d.ts +44 -0
- package/dist/internal/agent-output-env.js +57 -0
- package/dist/internal/argv-exec.d.ts +58 -0
- package/dist/internal/argv-exec.js +224 -0
- package/dist/internal/bounded-io.d.ts +24 -0
- package/dist/internal/bounded-io.js +51 -0
- package/dist/internal/command-spec/index.d.ts +29 -0
- package/dist/internal/command-spec/index.js +36 -0
- package/dist/internal/describe-value-type.d.ts +8 -0
- package/dist/internal/describe-value-type.js +14 -0
- package/dist/internal/exec-framing.d.ts +18 -0
- package/dist/internal/exec-framing.js +22 -0
- package/dist/internal/file-lock.d.ts +91 -0
- package/dist/internal/file-lock.js +212 -0
- package/dist/internal/file-size.d.ts +2 -0
- package/dist/internal/file-size.js +12 -0
- package/dist/internal/git-add.d.ts +43 -0
- package/dist/internal/git-add.js +59 -0
- package/dist/internal/git-env.d.ts +59 -0
- package/dist/internal/git-env.js +93 -0
- package/dist/internal/git-exec.d.ts +58 -0
- package/dist/internal/git-exec.js +115 -0
- package/dist/internal/path-file-lock.d.ts +23 -0
- package/dist/internal/path-file-lock.js +25 -0
- package/dist/internal/process-alive.d.ts +42 -0
- package/dist/internal/process-alive.js +56 -0
- package/dist/internal/process-kill.d.ts +27 -0
- package/dist/internal/process-kill.js +54 -0
- package/dist/internal/realpath.d.ts +29 -0
- package/dist/internal/realpath.js +52 -0
- package/dist/internal/redact.d.ts +40 -0
- package/dist/internal/redact.js +161 -0
- package/dist/internal/shell-quote.d.ts +2 -0
- package/dist/internal/shell-quote.js +4 -0
- package/dist/internal/sort.d.ts +20 -0
- package/dist/internal/sort.js +20 -0
- package/dist/internal/strip-control-chars.d.ts +19 -0
- package/dist/internal/strip-control-chars.js +38 -0
- package/dist/internal/thenable.d.ts +10 -0
- package/dist/internal/thenable.js +15 -0
- package/dist/internal.d.ts +91 -0
- package/dist/internal.js +91 -0
- package/dist/native/adapter-deps.d.ts +46 -0
- package/dist/native/adapter-deps.js +43 -0
- package/dist/native/auth-types.d.ts +70 -0
- package/dist/native/auth-types.js +13 -0
- package/dist/native/auth.d.ts +133 -0
- package/dist/native/auth.js +362 -0
- package/dist/native/client.d.ts +50 -0
- package/dist/native/client.js +174 -0
- package/dist/native/complete.d.ts +33 -0
- package/dist/native/complete.js +88 -0
- package/dist/native/credentials/chained-store.d.ts +30 -0
- package/dist/native/credentials/chained-store.js +68 -0
- package/dist/native/credentials/change-guard.d.ts +51 -0
- package/dist/native/credentials/change-guard.js +136 -0
- package/dist/native/credentials/exec-source.d.ts +50 -0
- package/dist/native/credentials/exec-source.js +222 -0
- package/dist/native/credentials/fingerprint.d.ts +26 -0
- package/dist/native/credentials/fingerprint.js +174 -0
- package/dist/native/credentials/helper-process.d.ts +79 -0
- package/dist/native/credentials/helper-process.js +259 -0
- package/dist/native/credentials/helper-protocol.d.ts +36 -0
- package/dist/native/credentials/helper-protocol.js +79 -0
- package/dist/native/credentials/index.d.ts +60 -0
- package/dist/native/credentials/index.js +184 -0
- package/dist/native/errors.d.ts +34 -0
- package/dist/native/errors.js +109 -0
- package/dist/native/index.d.ts +25 -0
- package/dist/native/index.js +23 -0
- package/dist/native/model-resolver.d.ts +57 -0
- package/dist/native/model-resolver.js +73 -0
- package/dist/native/models.d.ts +116 -0
- package/dist/native/models.js +183 -0
- package/dist/native/session/ask-human.d.ts +15 -0
- package/dist/native/session/ask-human.js +22 -0
- package/dist/native/session/compaction.d.ts +94 -0
- package/dist/native/session/compaction.js +170 -0
- package/dist/native/session/handle-invalid-tool-call.d.ts +111 -0
- package/dist/native/session/handle-invalid-tool-call.js +159 -0
- package/dist/native/session/loop-events/cache-boundary.d.ts +11 -0
- package/dist/native/session/loop-events/cache-boundary.js +29 -0
- package/dist/native/session/loop-events/external-handler.d.ts +38 -0
- package/dist/native/session/loop-events/external-handler.js +193 -0
- package/dist/native/session/loop-events/index.d.ts +16 -0
- package/dist/native/session/loop-events/index.js +15 -0
- package/dist/native/session/loop-events/payload-guard.d.ts +9 -0
- package/dist/native/session/loop-events/payload-guard.js +31 -0
- package/dist/native/session/loop-events/registry.d.ts +84 -0
- package/dist/native/session/loop-events/registry.js +305 -0
- package/dist/native/session/loop-events/types.d.ts +251 -0
- package/dist/native/session/loop-events/types.js +14 -0
- package/dist/native/session/loop-handlers.d.ts +73 -0
- package/dist/native/session/loop-handlers.js +137 -0
- package/dist/native/session/nudge.d.ts +29 -0
- package/dist/native/session/nudge.js +35 -0
- package/dist/native/session/rate-provenance.d.ts +17 -0
- package/dist/native/session/rate-provenance.js +39 -0
- package/dist/native/session/session.d.ts +139 -0
- package/dist/native/session/session.js +208 -0
- package/dist/native/session/tool-input-exemplar.d.ts +2 -0
- package/dist/native/session/tool-input-exemplar.js +66 -0
- package/dist/native/session/tool-input-validation.d.ts +15 -0
- package/dist/native/session/tool-input-validation.js +146 -0
- package/dist/native/session/tool-mapping.d.ts +18 -0
- package/dist/native/session/tool-mapping.js +27 -0
- package/dist/native/session/tool-result.d.ts +37 -0
- package/dist/native/session/tool-result.js +25 -0
- package/dist/native/session/transcript-store.d.ts +77 -0
- package/dist/native/session/transcript-store.js +200 -0
- package/dist/native/session/truncation-handler.d.ts +47 -0
- package/dist/native/session/truncation-handler.js +83 -0
- package/dist/native/session/turn-accumulator.d.ts +45 -0
- package/dist/native/session/turn-accumulator.js +67 -0
- package/dist/native/session/turn-ask-human.d.ts +23 -0
- package/dist/native/session/turn-ask-human.js +41 -0
- package/dist/native/session/turn-compaction-step.d.ts +74 -0
- package/dist/native/session/turn-compaction-step.js +128 -0
- package/dist/native/session/turn-complete-step.d.ts +77 -0
- package/dist/native/session/turn-complete-step.js +159 -0
- package/dist/native/session/turn-end-event.d.ts +3 -0
- package/dist/native/session/turn-end-event.js +17 -0
- package/dist/native/session/turn-events.d.ts +63 -0
- package/dist/native/session/turn-events.js +48 -0
- package/dist/native/session/turn-loop-round-trip.d.ts +116 -0
- package/dist/native/session/turn-loop-round-trip.js +318 -0
- package/dist/native/session/turn-loop.d.ts +23 -0
- package/dist/native/session/turn-loop.js +250 -0
- package/dist/native/session/turn-result.d.ts +39 -0
- package/dist/native/session/turn-result.js +64 -0
- package/dist/native/session/turn-retry.d.ts +120 -0
- package/dist/native/session/turn-retry.js +182 -0
- package/dist/native/session/turn-tool-batch.d.ts +61 -0
- package/dist/native/session/turn-tool-batch.js +259 -0
- package/dist/native/session/turn-types.d.ts +164 -0
- package/dist/native/session/turn-types.js +36 -0
- package/dist/native/session-adapter.d.ts +56 -0
- package/dist/native/session-adapter.js +369 -0
- package/dist/native/session-affinity.d.ts +36 -0
- package/dist/native/session-affinity.js +43 -0
- package/dist/native/tier-providers.d.ts +18 -0
- package/dist/native/tier-providers.js +20 -0
- package/dist/permissions/approval-audit.d.ts +10 -0
- package/dist/permissions/approval-audit.js +22 -0
- package/dist/permissions/approvals-link.d.ts +20 -0
- package/dist/permissions/approvals-link.js +62 -0
- package/dist/permissions/approvals-store.d.ts +168 -0
- package/dist/permissions/approvals-store.js +259 -0
- package/dist/permissions/approvals-taint.d.ts +33 -0
- package/dist/permissions/approvals-taint.js +138 -0
- package/dist/permissions/ask-chain.d.ts +69 -0
- package/dist/permissions/ask-chain.js +32 -0
- package/dist/permissions/ask.d.ts +23 -0
- package/dist/permissions/ask.js +25 -0
- package/dist/permissions/bash-lex.d.ts +59 -0
- package/dist/permissions/bash-lex.js +279 -0
- package/dist/permissions/grammar.d.ts +12 -0
- package/dist/permissions/grammar.js +24 -0
- package/dist/permissions/index.d.ts +11 -0
- package/dist/permissions/index.js +9 -0
- package/dist/permissions/secret-spans.d.ts +19 -0
- package/dist/permissions/secret-spans.js +88 -0
- package/dist/permissions/types.d.ts +85 -0
- package/dist/permissions/types.js +1 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/node-glob.d.ts +3 -0
- package/dist/runtime/node-glob.js +83 -0
- package/dist/runtime/node-runtime.d.ts +3 -0
- package/dist/runtime/node-runtime.js +126 -0
- package/dist/runtime/slot.d.ts +11 -0
- package/dist/runtime/slot.js +17 -0
- package/dist/runtime/types.d.ts +51 -0
- package/dist/runtime/types.js +1 -0
- package/dist/runtime/which.d.ts +8 -0
- package/dist/runtime/which.js +32 -0
- package/dist/sandbox/argv-quote.d.ts +11 -0
- package/dist/sandbox/argv-quote.js +13 -0
- package/dist/sandbox/defaults.d.ts +27 -0
- package/dist/sandbox/defaults.js +47 -0
- package/dist/sandbox/git-guards.d.ts +64 -0
- package/dist/sandbox/git-guards.js +161 -0
- package/dist/sandbox/index.d.ts +11 -0
- package/dist/sandbox/index.js +10 -0
- package/dist/sandbox/launcher.d.ts +37 -0
- package/dist/sandbox/launcher.js +160 -0
- package/dist/sandbox/messages.d.ts +17 -0
- package/dist/sandbox/messages.js +46 -0
- package/dist/sandbox/policy-builder.d.ts +44 -0
- package/dist/sandbox/policy-builder.js +149 -0
- package/dist/sandbox/policy-inputs.d.ts +53 -0
- package/dist/sandbox/policy-inputs.js +76 -0
- package/dist/sandbox/probe.d.ts +8 -0
- package/dist/sandbox/probe.js +66 -0
- package/dist/sandbox/registry.d.ts +17 -0
- package/dist/sandbox/registry.js +57 -0
- package/dist/sandbox/session-tmp.d.ts +20 -0
- package/dist/sandbox/session-tmp.js +95 -0
- package/dist/sandbox/srt-backend.d.ts +21 -0
- package/dist/sandbox/srt-backend.js +99 -0
- package/dist/sandbox/types.d.ts +102 -0
- package/dist/sandbox/types.js +1 -0
- package/dist/session/adapter-failure.d.ts +60 -0
- package/dist/session/adapter-failure.js +11 -0
- package/dist/session/agent-stream-events.d.ts +103 -0
- package/dist/session/agent-stream-events.js +6 -0
- package/dist/session/interaction-handler.d.ts +53 -0
- package/dist/session/interaction-handler.js +2 -0
- package/dist/session/no-op-interaction-handler.d.ts +9 -0
- package/dist/session/no-op-interaction-handler.js +11 -0
- package/dist/session/protocol-types.d.ts +12 -0
- package/dist/session/protocol-types.js +1 -0
- package/dist/session/session-types.d.ts +456 -0
- package/dist/session/session-types.js +64 -0
- package/dist/session/tool-descriptor.d.ts +23 -0
- package/dist/session/tool-descriptor.js +5 -0
- package/dist/session/turn-deadline.d.ts +17 -0
- package/dist/session/turn-deadline.js +23 -0
- package/dist/tools/ask-request.d.ts +35 -0
- package/dist/tools/ask-request.js +57 -0
- package/dist/tools/bash-cwd.d.ts +82 -0
- package/dist/tools/bash-cwd.js +55 -0
- package/dist/tools/bash.d.ts +69 -0
- package/dist/tools/bash.js +283 -0
- package/dist/tools/delete.d.ts +57 -0
- package/dist/tools/delete.js +154 -0
- package/dist/tools/denial-redirect.d.ts +42 -0
- package/dist/tools/denial-redirect.js +266 -0
- package/dist/tools/deny-paths.d.ts +35 -0
- package/dist/tools/deny-paths.js +76 -0
- package/dist/tools/edit-region.d.ts +50 -0
- package/dist/tools/edit-region.js +90 -0
- package/dist/tools/edit.d.ts +25 -0
- package/dist/tools/edit.js +114 -0
- package/dist/tools/exec-allowlist-text.d.ts +18 -0
- package/dist/tools/exec-allowlist-text.js +24 -0
- package/dist/tools/exec-guard.d.ts +69 -0
- package/dist/tools/exec-guard.js +151 -0
- package/dist/tools/git-commit.d.ts +59 -0
- package/dist/tools/git-commit.js +220 -0
- package/dist/tools/git-flags/index.d.ts +10 -0
- package/dist/tools/git-flags/index.js +10 -0
- package/dist/tools/git.d.ts +88 -0
- package/dist/tools/git.js +397 -0
- package/dist/tools/glob.d.ts +29 -0
- package/dist/tools/glob.js +153 -0
- package/dist/tools/grep.d.ts +21 -0
- package/dist/tools/grep.js +162 -0
- package/dist/tools/index.d.ts +36 -0
- package/dist/tools/index.js +28 -0
- package/dist/tools/narrow-grants.d.ts +19 -0
- package/dist/tools/narrow-grants.js +54 -0
- package/dist/tools/nax-owned-writes.d.ts +135 -0
- package/dist/tools/nax-owned-writes.js +259 -0
- package/dist/tools/package-managers-table.d.ts +146 -0
- package/dist/tools/package-managers-table.js +325 -0
- package/dist/tools/package-managers-types.d.ts +36 -0
- package/dist/tools/package-managers-types.js +6 -0
- package/dist/tools/package-managers.d.ts +85 -0
- package/dist/tools/package-managers.js +391 -0
- package/dist/tools/path-list.d.ts +38 -0
- package/dist/tools/path-list.js +62 -0
- package/dist/tools/policy-bash-raw.d.ts +19 -0
- package/dist/tools/policy-bash-raw.js +210 -0
- package/dist/tools/policy-bash.d.ts +64 -0
- package/dist/tools/policy-bash.js +232 -0
- package/dist/tools/policy-command-branch.d.ts +32 -0
- package/dist/tools/policy-command-branch.js +63 -0
- package/dist/tools/policy-input.d.ts +2 -0
- package/dist/tools/policy-input.js +10 -0
- package/dist/tools/policy-match.d.ts +75 -0
- package/dist/tools/policy-match.js +117 -0
- package/dist/tools/policy-paths-branch.d.ts +97 -0
- package/dist/tools/policy-paths-branch.js +314 -0
- package/dist/tools/policy.d.ts +65 -0
- package/dist/tools/policy.js +232 -0
- package/dist/tools/protected-paths.d.ts +26 -0
- package/dist/tools/protected-paths.js +8 -0
- package/dist/tools/provider-adapt.d.ts +4 -0
- package/dist/tools/provider-adapt.js +32 -0
- package/dist/tools/provider-advertise.d.ts +39 -0
- package/dist/tools/provider-advertise.js +50 -0
- package/dist/tools/provider-grants.d.ts +36 -0
- package/dist/tools/provider-grants.js +72 -0
- package/dist/tools/provider-sanitize.d.ts +19 -0
- package/dist/tools/provider-sanitize.js +53 -0
- package/dist/tools/provider-types.d.ts +41 -0
- package/dist/tools/provider-types.js +26 -0
- package/dist/tools/read-continuation.d.ts +147 -0
- package/dist/tools/read-continuation.js +176 -0
- package/dist/tools/read-file.d.ts +32 -0
- package/dist/tools/read-file.js +85 -0
- package/dist/tools/read.d.ts +25 -0
- package/dist/tools/read.js +182 -0
- package/dist/tools/registry.d.ts +125 -0
- package/dist/tools/registry.js +67 -0
- package/dist/tools/request-capability.d.ts +13 -0
- package/dist/tools/request-capability.js +23 -0
- package/dist/tools/run-command-exec.d.ts +14 -0
- package/dist/tools/run-command-exec.js +161 -0
- package/dist/tools/run-command.d.ts +107 -0
- package/dist/tools/run-command.js +398 -0
- package/dist/tools/runtime-calltool.d.ts +146 -0
- package/dist/tools/runtime-calltool.js +223 -0
- package/dist/tools/runtime.d.ts +149 -0
- package/dist/tools/runtime.js +345 -0
- package/dist/tools/scratchpad.d.ts +20 -0
- package/dist/tools/scratchpad.js +197 -0
- package/dist/tools/spill.d.ts +81 -0
- package/dist/tools/spill.js +251 -0
- package/dist/tools/tool-audit.d.ts +171 -0
- package/dist/tools/tool-audit.js +183 -0
- package/dist/tools/truncate.d.ts +98 -0
- package/dist/tools/truncate.js +297 -0
- package/dist/tools/types.d.ts +145 -0
- package/dist/tools/types.js +24 -0
- package/dist/tools/verb-denial-argv-hint.d.ts +35 -0
- package/dist/tools/verb-denial-argv-hint.js +40 -0
- package/dist/tools/write.d.ts +9 -0
- package/dist/tools/write.js +45 -0
- package/package.json +54 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed credential fingerprints (US-002).
|
|
3
|
+
*
|
|
4
|
+
* A fingerprint lets a run report "the credential this call was billed to has
|
|
5
|
+
* changed" without ever logging the credential: the first 12 lowercase hex
|
|
6
|
+
* characters of HMAC-SHA-256 over the credential's identifying secret, keyed by
|
|
7
|
+
* a per-machine salt held in `<configDir>/auth-fingerprint-salt`.
|
|
8
|
+
*
|
|
9
|
+
* The salt is what makes the digest useless as a lookup key, and it is per
|
|
10
|
+
* machine by design — a fingerprint cannot be compared across machines. It is
|
|
11
|
+
* published atomically at mode 0600, so a writer that loses the creation race
|
|
12
|
+
* reads the winner's complete file instead of overwriting it.
|
|
13
|
+
*/
|
|
14
|
+
import type { StoredCredential } from "@nathapp/nax-ai";
|
|
15
|
+
/** Clears the salt memo and the warn-once set. Tests only. */
|
|
16
|
+
export declare function _resetFingerprintSalt(): void;
|
|
17
|
+
/**
|
|
18
|
+
* The first 12 lowercase hex characters of HMAC-SHA-256 over the credential's
|
|
19
|
+
* identifying secret, keyed by the machine salt.
|
|
20
|
+
*
|
|
21
|
+
* `api-key` fingerprints its `key` as written — a `$VAR` or `!command` template
|
|
22
|
+
* is fingerprinted as the template, because nax-ai documents `key` as opaque.
|
|
23
|
+
* `oauth` fingerprints its `refresh` token, so access-token rotation is
|
|
24
|
+
* invisible while refresh-token rotation is not.
|
|
25
|
+
*/
|
|
26
|
+
export declare function fingerprintCredential(credential: StoredCredential): Promise<string>;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed credential fingerprints (US-002).
|
|
3
|
+
*
|
|
4
|
+
* A fingerprint lets a run report "the credential this call was billed to has
|
|
5
|
+
* changed" without ever logging the credential: the first 12 lowercase hex
|
|
6
|
+
* characters of HMAC-SHA-256 over the credential's identifying secret, keyed by
|
|
7
|
+
* a per-machine salt held in `<configDir>/auth-fingerprint-salt`.
|
|
8
|
+
*
|
|
9
|
+
* The salt is what makes the digest useless as a lookup key, and it is per
|
|
10
|
+
* machine by design — a fingerprint cannot be compared across machines. It is
|
|
11
|
+
* published atomically at mode 0600, so a writer that loses the creation race
|
|
12
|
+
* reads the winner's complete file instead of overwriting it.
|
|
13
|
+
*/
|
|
14
|
+
import { createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
15
|
+
import { link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { errorMessage } from "#src/infra/errors";
|
|
18
|
+
import { credentialsConfig, getSafeLogger } from "#src/infra/index";
|
|
19
|
+
/** Exactly one HMAC key's worth. Anything shorter is a damaged file, not a salt. */
|
|
20
|
+
const SALT_BYTES = 32;
|
|
21
|
+
const SALT_FILENAME = "auth-fingerprint-salt";
|
|
22
|
+
/** Lowercase hex characters retained from the digest. */
|
|
23
|
+
const FINGERPRINT_CHARS = 12;
|
|
24
|
+
/**
|
|
25
|
+
* Salt memo, and the salt-file path it belongs to: `NAX_GLOBAL_CONFIG_DIR`
|
|
26
|
+
* moves between tests, and a salt cached for a stale path would make every
|
|
27
|
+
* fingerprint in the new directory wrong.
|
|
28
|
+
*/
|
|
29
|
+
let memoPath;
|
|
30
|
+
let memoSalt;
|
|
31
|
+
/** Salt-file paths already reported invalid, so the event is emitted once per file. */
|
|
32
|
+
const warnedInvalidPaths = new Set();
|
|
33
|
+
/** Clears the salt memo and the warn-once set. Tests only. */
|
|
34
|
+
export function _resetFingerprintSalt() {
|
|
35
|
+
memoPath = undefined;
|
|
36
|
+
memoSalt = undefined;
|
|
37
|
+
warnedInvalidPaths.clear();
|
|
38
|
+
}
|
|
39
|
+
/** Absolute path of the per-machine salt file. */
|
|
40
|
+
function saltFilePath() {
|
|
41
|
+
return join(credentialsConfig().configDir(), SALT_FILENAME);
|
|
42
|
+
}
|
|
43
|
+
/** Read the salt file. `undefined` when it does not exist. */
|
|
44
|
+
async function readSaltFile(path) {
|
|
45
|
+
try {
|
|
46
|
+
return await readFile(path);
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error.code === "ENOENT")
|
|
50
|
+
return undefined;
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Create the salt file with its bytes already in place, or read the winner's
|
|
56
|
+
* when another writer beat us to it.
|
|
57
|
+
*
|
|
58
|
+
* `open(path, "wx")` publishes the name before the payload lands, so a process
|
|
59
|
+
* that lost the creation race could read a 0-byte file, call it invalid and
|
|
60
|
+
* fingerprint with an in-memory salt nobody else on the machine shares. Staging
|
|
61
|
+
* the bytes and hard-linking them into place publishes the name only once the
|
|
62
|
+
* content exists: `link` fails with EEXIST when another writer got there first,
|
|
63
|
+
* and a hard link shares the inode, mode 0600 included.
|
|
64
|
+
*
|
|
65
|
+
* The staging name is unique per attempt, so two concurrent callers inside one
|
|
66
|
+
* process cannot overwrite each other's bytes.
|
|
67
|
+
*/
|
|
68
|
+
async function createSaltFile(path) {
|
|
69
|
+
// A fresh machine has no global config dir yet, and an exclusive create cannot
|
|
70
|
+
// make one: an absent parent surfaces as ENOENT, not EEXIST, so the first
|
|
71
|
+
// fingerprint would throw instead of creating the salt.
|
|
72
|
+
await mkdir(dirname(path), { recursive: true });
|
|
73
|
+
const candidate = randomBytes(SALT_BYTES);
|
|
74
|
+
const staged = `${path}.${randomUUID()}.staged`;
|
|
75
|
+
try {
|
|
76
|
+
await writeFile(staged, candidate, { mode: 0o600 });
|
|
77
|
+
try {
|
|
78
|
+
await link(staged, path);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error.code === "EEXIST")
|
|
82
|
+
return readSaltFile(path);
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
return candidate;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
await rm(staged, { force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Load the machine salt, creating it on first use.
|
|
93
|
+
*
|
|
94
|
+
* A file that is not exactly 32 bytes is left untouched: it is replaced by a
|
|
95
|
+
* random in-memory salt for this process, and `credential.salt_invalid` is
|
|
96
|
+
* logged once for that path. Rewriting the file would destroy whatever an
|
|
97
|
+
* operator put there, and reusing a short read would be worse than a fresh key.
|
|
98
|
+
*
|
|
99
|
+
* Any filesystem failure resolves the same way. This runs on the run-start probe
|
|
100
|
+
* (`providersWithoutCredentials`), where a raw `EACCES`/`EISDIR` would escape as a
|
|
101
|
+
* non-`NaxError` and take the whole precheck report down with it.
|
|
102
|
+
*
|
|
103
|
+
* The trade is deliberate and it is a real loss: a random salt breaks the
|
|
104
|
+
* cross-process comparison on THIS machine, which is the one thing a per-machine
|
|
105
|
+
* salt exists to provide (fingerprints still cannot cross machines either way —
|
|
106
|
+
* that is what the salt buys). So a run whose `~/.nax` is unwritable reports
|
|
107
|
+
* `credential.changed`/`credential.renewed` against a baseline no sibling process
|
|
108
|
+
* shares, and its `auth` fingerprints do not line up with the next run's. The
|
|
109
|
+
* errno rides along on the event so an operator can tell a permissions problem
|
|
110
|
+
* from a truncated file, which is the difference between a fixable machine and a
|
|
111
|
+
* mysterious one.
|
|
112
|
+
*/
|
|
113
|
+
async function loadSalt(path) {
|
|
114
|
+
let stored;
|
|
115
|
+
let failure;
|
|
116
|
+
try {
|
|
117
|
+
stored = (await readSaltFile(path)) ?? (await createSaltFile(path));
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
stored = undefined;
|
|
121
|
+
failure = error;
|
|
122
|
+
}
|
|
123
|
+
if (stored !== undefined && stored.byteLength === SALT_BYTES)
|
|
124
|
+
return stored;
|
|
125
|
+
if (!warnedInvalidPaths.has(path)) {
|
|
126
|
+
warnedInvalidPaths.add(path);
|
|
127
|
+
getSafeLogger()?.warn("credentials", "credential.salt_invalid", {
|
|
128
|
+
saltPath: path,
|
|
129
|
+
...(failure !== undefined
|
|
130
|
+
? { errno: failure.code, error: errorMessage(failure) }
|
|
131
|
+
: {}),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return randomBytes(SALT_BYTES);
|
|
135
|
+
}
|
|
136
|
+
/** The salt this process fingerprints with, memoised per salt-file path. */
|
|
137
|
+
async function resolveSalt() {
|
|
138
|
+
const path = saltFilePath();
|
|
139
|
+
if (memoSalt !== undefined && memoPath === path)
|
|
140
|
+
return memoSalt;
|
|
141
|
+
const salt = await loadSalt(path);
|
|
142
|
+
memoPath = path;
|
|
143
|
+
memoSalt = salt;
|
|
144
|
+
return salt;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The part of a credential that identifies it.
|
|
148
|
+
*
|
|
149
|
+
* Exhaustive over the union on purpose: a third `StoredCredential` kind must
|
|
150
|
+
* fail the build here, not silently hash whichever field the `else` branch
|
|
151
|
+
* happened to name.
|
|
152
|
+
*/
|
|
153
|
+
function identifyingSecret(credential) {
|
|
154
|
+
switch (credential.kind) {
|
|
155
|
+
case "api-key":
|
|
156
|
+
return credential.key;
|
|
157
|
+
case "oauth":
|
|
158
|
+
return credential.refresh;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The first 12 lowercase hex characters of HMAC-SHA-256 over the credential's
|
|
163
|
+
* identifying secret, keyed by the machine salt.
|
|
164
|
+
*
|
|
165
|
+
* `api-key` fingerprints its `key` as written — a `$VAR` or `!command` template
|
|
166
|
+
* is fingerprinted as the template, because nax-ai documents `key` as opaque.
|
|
167
|
+
* `oauth` fingerprints its `refresh` token, so access-token rotation is
|
|
168
|
+
* invisible while refresh-token rotation is not.
|
|
169
|
+
*/
|
|
170
|
+
export async function fingerprintCredential(credential) {
|
|
171
|
+
const salt = await resolveSalt();
|
|
172
|
+
const secret = identifyingSecret(credential);
|
|
173
|
+
return createHmac("sha256", salt).update(secret).digest("hex").slice(0, FINGERPRINT_CHARS);
|
|
174
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exec helper's process boundary (US-003): spawn it, feed it one request
|
|
3
|
+
* line, bound both streams, and report what came back.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here chooses a credential error code — that mapping belongs to
|
|
6
|
+
* exec-source.ts. What this module owns is the deadline, the stdout cap, the
|
|
7
|
+
* bounded drain after the process is gone, and the secrecy rule: stderr is
|
|
8
|
+
* redacted *before* it is truncated, so truncation can never cut a secret into a
|
|
9
|
+
* fragment the redaction patterns no longer recognise.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Hard cap on the helper's stdout. Over it the reply is malformed and the
|
|
13
|
+
* process is killed. Not configurable (US-003 scope).
|
|
14
|
+
*/
|
|
15
|
+
export declare const AUTH_HELPER_STDOUT_MAX_BYTES = 65536;
|
|
16
|
+
/** Hard cap on the stderr excerpt that may reach a log line or an error message. */
|
|
17
|
+
export declare const AUTH_HELPER_STDERR_MAX_BYTES = 4096;
|
|
18
|
+
/** The slice of the runtime's spawn result this module uses. */
|
|
19
|
+
interface HelperProcess {
|
|
20
|
+
readonly stdin: {
|
|
21
|
+
write(data: string): unknown;
|
|
22
|
+
end(): unknown;
|
|
23
|
+
};
|
|
24
|
+
readonly stdout: ReadableStream<Uint8Array>;
|
|
25
|
+
readonly stderr: ReadableStream<Uint8Array>;
|
|
26
|
+
readonly exited: Promise<number>;
|
|
27
|
+
readonly exitCode: number | null;
|
|
28
|
+
/**
|
|
29
|
+
* Always SIGKILL. A deadline that a helper can ignore is not a deadline: the
|
|
30
|
+
* call would wait on a process that never exits, and `read` would never settle.
|
|
31
|
+
*/
|
|
32
|
+
kill(signal: "SIGKILL"): void;
|
|
33
|
+
}
|
|
34
|
+
declare function spawnHelper(argv: readonly string[]): HelperProcess;
|
|
35
|
+
/**
|
|
36
|
+
* Injectable seam (the `_deps` pattern): every external call this module makes
|
|
37
|
+
* goes through it, so a test can drive the helper without a real process.
|
|
38
|
+
*
|
|
39
|
+
* The spawn deliberately passes no `env`: the child inherits nax's environment,
|
|
40
|
+
* which is what the helper contract promises.
|
|
41
|
+
*/
|
|
42
|
+
export declare const _execSourceDeps: {
|
|
43
|
+
spawn: typeof spawnHelper;
|
|
44
|
+
/**
|
|
45
|
+
* Timer pair, wrapped so the global functions are resolved per call — a test
|
|
46
|
+
* that spies on `setTimeout` (the timer-leak helper) still sees these.
|
|
47
|
+
*/
|
|
48
|
+
setTimeout: (fn: () => void, ms: number) => unknown;
|
|
49
|
+
clearTimeout: (id: unknown) => void;
|
|
50
|
+
};
|
|
51
|
+
/** What one helper invocation produced, as the process boundary saw it. */
|
|
52
|
+
export type HelperProcessResult = {
|
|
53
|
+
kind: "spawn-failed";
|
|
54
|
+
detail: string;
|
|
55
|
+
cause: unknown;
|
|
56
|
+
stderr: string;
|
|
57
|
+
} | {
|
|
58
|
+
kind: "timed-out";
|
|
59
|
+
detail: string;
|
|
60
|
+
stderr: string;
|
|
61
|
+
} | {
|
|
62
|
+
kind: "stdout-over-cap";
|
|
63
|
+
detail: string;
|
|
64
|
+
stderr: string;
|
|
65
|
+
}
|
|
66
|
+
/** No reply to judge: the process was killed, or its streams failed under us. */
|
|
67
|
+
| {
|
|
68
|
+
kind: "no-answer";
|
|
69
|
+
detail: string;
|
|
70
|
+
stderr: string;
|
|
71
|
+
} | {
|
|
72
|
+
kind: "exited";
|
|
73
|
+
exitCode: number;
|
|
74
|
+
stdout: string;
|
|
75
|
+
stderr: string;
|
|
76
|
+
};
|
|
77
|
+
/** Spawn the helper for `providerId` and report what it produced. Never throws for a helper failure. */
|
|
78
|
+
export declare function runHelper(command: readonly string[], providerId: string, timeoutMs: number): Promise<HelperProcessResult>;
|
|
79
|
+
export {};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exec helper's process boundary (US-003): spawn it, feed it one request
|
|
3
|
+
* line, bound both streams, and report what came back.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here chooses a credential error code — that mapping belongs to
|
|
6
|
+
* exec-source.ts. What this module owns is the deadline, the stdout cap, the
|
|
7
|
+
* bounded drain after the process is gone, and the secrecy rule: stderr is
|
|
8
|
+
* redacted *before* it is truncated, so truncation can never cut a secret into a
|
|
9
|
+
* fragment the redaction patterns no longer recognise.
|
|
10
|
+
*/
|
|
11
|
+
import { errorMessage } from "#src/infra/errors";
|
|
12
|
+
import { getSafeLogger, NaxError } from "#src/infra/index";
|
|
13
|
+
import { redactSecrets } from "#src/internal/redact";
|
|
14
|
+
import { runtimeSpawn } from "#src/runtime/index";
|
|
15
|
+
import { HELPER_SUBCOMMAND, requestLine } from "./helper-protocol.js";
|
|
16
|
+
/**
|
|
17
|
+
* Hard cap on the helper's stdout. Over it the reply is malformed and the
|
|
18
|
+
* process is killed. Not configurable (US-003 scope).
|
|
19
|
+
*/
|
|
20
|
+
export const AUTH_HELPER_STDOUT_MAX_BYTES = 65_536;
|
|
21
|
+
/** Hard cap on the stderr excerpt that may reach a log line or an error message. */
|
|
22
|
+
export const AUTH_HELPER_STDERR_MAX_BYTES = 4_096;
|
|
23
|
+
/**
|
|
24
|
+
* How much stderr is collected before further bytes are dropped. Redaction runs
|
|
25
|
+
* over the whole collected prefix and only AUTH_HELPER_STDERR_MAX_BYTES of the
|
|
26
|
+
* result is kept, so the 8× margin leaves a secret that straddles the
|
|
27
|
+
* truncation point fully visible to the redaction patterns.
|
|
28
|
+
*/
|
|
29
|
+
const AUTH_HELPER_STDERR_COLLECT_MAX_BYTES = AUTH_HELPER_STDERR_MAX_BYTES * 8;
|
|
30
|
+
/**
|
|
31
|
+
* How long the readers may keep waiting once the process is gone. A helper's
|
|
32
|
+
* pipes close the instant it exits; only an inherited pipe — a descendant of a
|
|
33
|
+
* killed helper, such as the `sleep` or the network client it shelled out to —
|
|
34
|
+
* outlives it, and that one will never end on its own.
|
|
35
|
+
*/
|
|
36
|
+
const DRAIN_GRACE_MS = 500;
|
|
37
|
+
/** A piped spawn always has stdin; a runtime that broke that is a spawn failure, caught by the caller. */
|
|
38
|
+
function hasStdin(proc) {
|
|
39
|
+
return proc.stdin !== undefined;
|
|
40
|
+
}
|
|
41
|
+
function spawnHelper(argv) {
|
|
42
|
+
// nax-git-env-allow: caller-supplied helper argv, not git; the child inherits nax's env by contract
|
|
43
|
+
const proc = runtimeSpawn(argv, { stdin: "pipe", stdout: "pipe", stderr: "pipe" });
|
|
44
|
+
if (!hasStdin(proc)) {
|
|
45
|
+
throw new NaxError("[credentials] the runtime returned no stdin for a piped spawn", "CREDENTIAL_HELPER_FAILED", {
|
|
46
|
+
stage: "credentials",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return proc;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Injectable seam (the `_deps` pattern): every external call this module makes
|
|
53
|
+
* goes through it, so a test can drive the helper without a real process.
|
|
54
|
+
*
|
|
55
|
+
* The spawn deliberately passes no `env`: the child inherits nax's environment,
|
|
56
|
+
* which is what the helper contract promises.
|
|
57
|
+
*/
|
|
58
|
+
export const _execSourceDeps = {
|
|
59
|
+
spawn: spawnHelper,
|
|
60
|
+
/**
|
|
61
|
+
* Timer pair, wrapped so the global functions are resolved per call — a test
|
|
62
|
+
* that spies on `setTimeout` (the timer-leak helper) still sees these.
|
|
63
|
+
*/
|
|
64
|
+
setTimeout: ((fn, ms) => setTimeout(fn, ms)),
|
|
65
|
+
clearTimeout: ((id) => clearTimeout(id)),
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Read a stream to EOF, decoding UTF-8.
|
|
69
|
+
*
|
|
70
|
+
* Reads run concurrently with the exit wait: a child that fills the OS pipe
|
|
71
|
+
* buffer before anyone reads it would otherwise block on its own write and never
|
|
72
|
+
* reach `exited`, which would leave the timeout as the only way out.
|
|
73
|
+
*
|
|
74
|
+
* Past `cap` the two callers differ. With `onExceeded` the stream is abandoned —
|
|
75
|
+
* the callback kills the process, so there is nothing left to drain. Without it
|
|
76
|
+
* the stream is still drained (a full pipe must not wedge the child) but nothing
|
|
77
|
+
* more is kept.
|
|
78
|
+
*/
|
|
79
|
+
async function readCapped(stream, cap, options = {}) {
|
|
80
|
+
const reader = stream.getReader();
|
|
81
|
+
const decoder = new TextDecoder();
|
|
82
|
+
let text = "";
|
|
83
|
+
let bytes = 0;
|
|
84
|
+
let exceeded = false;
|
|
85
|
+
/** Abort closes the stream, which resolves the pending `read()` with `done`. */
|
|
86
|
+
const onAbort = () => {
|
|
87
|
+
reader.cancel().catch(() => { });
|
|
88
|
+
};
|
|
89
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
90
|
+
try {
|
|
91
|
+
while (true) {
|
|
92
|
+
const { done, value } = await reader.read();
|
|
93
|
+
if (done === true)
|
|
94
|
+
break;
|
|
95
|
+
if (value === undefined)
|
|
96
|
+
continue;
|
|
97
|
+
bytes += value.byteLength;
|
|
98
|
+
if (bytes > cap && !exceeded) {
|
|
99
|
+
exceeded = true;
|
|
100
|
+
if (options.onExceeded !== undefined) {
|
|
101
|
+
options.onExceeded();
|
|
102
|
+
// The process is dying; do not wait for its pipe to notice.
|
|
103
|
+
await reader.cancel().catch(() => { });
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (!exceeded)
|
|
108
|
+
text += decoder.decode(value, { stream: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// The pipe closed under us — a kill, an abort, or a child that exited
|
|
113
|
+
// mid-write. Whatever was collected before that still stands.
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
117
|
+
}
|
|
118
|
+
if (!exceeded)
|
|
119
|
+
text += decoder.decode();
|
|
120
|
+
return { text, exceeded };
|
|
121
|
+
}
|
|
122
|
+
/** Write the one-line request, then close stdin so the helper sees EOF. */
|
|
123
|
+
function writeRequest(proc, providerId) {
|
|
124
|
+
try {
|
|
125
|
+
proc.stdin.write(requestLine(providerId));
|
|
126
|
+
proc.stdin.end();
|
|
127
|
+
}
|
|
128
|
+
catch (cause) {
|
|
129
|
+
// A helper that exits without draining stdin surfaces EPIPE here. Its exit
|
|
130
|
+
// code is the verdict, so this is a diagnostic, not a failure of its own.
|
|
131
|
+
getSafeLogger()?.debug("credentials", "credential.helper_stdin_failed", {
|
|
132
|
+
providerId,
|
|
133
|
+
error: errorMessage(cause),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Collect both streams, bounded by DRAIN_GRACE_MS once the process has exited.
|
|
139
|
+
*
|
|
140
|
+
* A helper's pipes close as it exits — unless it left a descendant holding them,
|
|
141
|
+
* and a killed helper leaves exactly that whenever it shelled out. That pipe
|
|
142
|
+
* never closes on its own, so it must not be allowed to hold the read past the
|
|
143
|
+
* deadline the source promises: whatever arrived within the grace is kept, and
|
|
144
|
+
* the rest is abandoned.
|
|
145
|
+
*/
|
|
146
|
+
async function drainStreams(streams) {
|
|
147
|
+
let graceTimer;
|
|
148
|
+
const grace = new Promise((resolve) => {
|
|
149
|
+
graceTimer = _execSourceDeps.setTimeout(() => resolve(null), DRAIN_GRACE_MS);
|
|
150
|
+
});
|
|
151
|
+
try {
|
|
152
|
+
const drained = await Promise.race([Promise.all([streams.stdout, streams.stderr]), grace]);
|
|
153
|
+
if (drained !== null)
|
|
154
|
+
return drained;
|
|
155
|
+
for (const controller of streams.controllers)
|
|
156
|
+
controller.abort();
|
|
157
|
+
return await Promise.all([streams.stdout, streams.stderr]);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
// Cleared on both paths: an armed timer outliving the call keeps the event
|
|
161
|
+
// loop alive after the read has already resolved.
|
|
162
|
+
_execSourceDeps.clearTimeout(graceTimer);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* SIGKILL a helper, tolerating a child that is already gone.
|
|
167
|
+
*
|
|
168
|
+
* A deadline that a helper can ignore is not a deadline: the call would wait on
|
|
169
|
+
* a process that never exits, and `read` would never settle. The `try` is for the
|
|
170
|
+
* race where the child is reaped between the decision to kill it and the signal.
|
|
171
|
+
*/
|
|
172
|
+
function killHelper(proc) {
|
|
173
|
+
try {
|
|
174
|
+
proc.kill("SIGKILL");
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// The child was already gone; its exit code decides the outcome either way.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/** Run one helper process to its end, killing it if either kill switch trips first. */
|
|
181
|
+
async function runHelperProcess(proc, providerId, timeoutMs) {
|
|
182
|
+
// Whichever trips first decides the outcome; both kill the process.
|
|
183
|
+
let trip;
|
|
184
|
+
const timer = _execSourceDeps.setTimeout(() => {
|
|
185
|
+
if (trip === undefined)
|
|
186
|
+
trip = "timeout";
|
|
187
|
+
killHelper(proc);
|
|
188
|
+
}, timeoutMs);
|
|
189
|
+
const stdoutController = new AbortController();
|
|
190
|
+
const stderrController = new AbortController();
|
|
191
|
+
try {
|
|
192
|
+
writeRequest(proc, providerId);
|
|
193
|
+
// Reading starts here, concurrently with the exit wait below.
|
|
194
|
+
const streams = {
|
|
195
|
+
stdout: readCapped(proc.stdout, AUTH_HELPER_STDOUT_MAX_BYTES, {
|
|
196
|
+
signal: stdoutController.signal,
|
|
197
|
+
onExceeded: () => {
|
|
198
|
+
if (trip === undefined)
|
|
199
|
+
trip = "cap";
|
|
200
|
+
killHelper(proc);
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
stderr: readCapped(proc.stderr, AUTH_HELPER_STDERR_COLLECT_MAX_BYTES, { signal: stderrController.signal }),
|
|
204
|
+
controllers: [stdoutController, stderrController],
|
|
205
|
+
};
|
|
206
|
+
await proc.exited;
|
|
207
|
+
const [stdout, stderr] = await drainStreams(streams);
|
|
208
|
+
return { trip, exitCode: proc.exitCode, stdout, stderr: stderr.text };
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
// Cleared however the call ends, including the timeout: a timer left armed
|
|
212
|
+
// holds the event loop open after the read has already resolved.
|
|
213
|
+
_execSourceDeps.clearTimeout(timer);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Spawn the helper for `providerId` and report what it produced. Never throws for a helper failure. */
|
|
217
|
+
export async function runHelper(command, providerId, timeoutMs) {
|
|
218
|
+
let proc;
|
|
219
|
+
try {
|
|
220
|
+
// nax-git-env-allow: caller-supplied helper argv, not git; the child inherits nax's env by contract
|
|
221
|
+
proc = _execSourceDeps.spawn([...command, HELPER_SUBCOMMAND]);
|
|
222
|
+
}
|
|
223
|
+
catch (cause) {
|
|
224
|
+
// A missing binary never reaches `exited`: the runtime throws ENOENT from spawn.
|
|
225
|
+
return { kind: "spawn-failed", detail: `could not be started: ${errorMessage(cause)}`, cause, stderr: "" };
|
|
226
|
+
}
|
|
227
|
+
let run;
|
|
228
|
+
try {
|
|
229
|
+
run = await runHelperProcess(proc, providerId, timeoutMs);
|
|
230
|
+
}
|
|
231
|
+
catch (cause) {
|
|
232
|
+
// The streams or the exit wait failed under us, so nothing was learned about
|
|
233
|
+
// the provider. The child is NOT necessarily dead, though: the `finally` in
|
|
234
|
+
// runHelperProcess has already cleared the deadline timer that would have
|
|
235
|
+
// killed it, and that call's abort controllers died with the throw. Left
|
|
236
|
+
// alone it runs until it chooses to stop, holding two open pipes. Kill it
|
|
237
|
+
// and drop the readers here, where `proc` is still in scope.
|
|
238
|
+
killHelper(proc);
|
|
239
|
+
await Promise.allSettled([proc.stdout.cancel(), proc.stderr.cancel()]);
|
|
240
|
+
return { kind: "no-answer", detail: `could not be read: ${errorMessage(cause)}`, stderr: "" };
|
|
241
|
+
}
|
|
242
|
+
// Redact first, truncate second. The other order can cut a secret in half and
|
|
243
|
+
// leave a fragment no redaction pattern can recognise.
|
|
244
|
+
const stderr = redactSecrets(run.stderr).slice(0, AUTH_HELPER_STDERR_MAX_BYTES);
|
|
245
|
+
if (run.trip === "cap") {
|
|
246
|
+
return {
|
|
247
|
+
kind: "stdout-over-cap",
|
|
248
|
+
detail: `wrote more than ${AUTH_HELPER_STDOUT_MAX_BYTES} bytes to stdout`,
|
|
249
|
+
stderr,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (run.trip === "timeout") {
|
|
253
|
+
return { kind: "timed-out", detail: `did not answer within ${timeoutMs}ms`, stderr };
|
|
254
|
+
}
|
|
255
|
+
if (run.exitCode === null) {
|
|
256
|
+
return { kind: "no-answer", detail: "was killed before it could answer", stderr };
|
|
257
|
+
}
|
|
258
|
+
return { kind: "exited", exitCode: run.exitCode, stdout: run.stdout.text, stderr };
|
|
259
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exec helper's wire contract (US-003): one request line in, one reply line
|
|
3
|
+
* out.
|
|
4
|
+
*
|
|
5
|
+
* Held apart from the process boundary (helper-process.ts) and the source itself
|
|
6
|
+
* (exec-source.ts) because this is the part koda implements against, and because
|
|
7
|
+
* every rule here is fail-closed: anything this module cannot name with certainty
|
|
8
|
+
* is invalid, never a best-effort read of the nearest field.
|
|
9
|
+
*/
|
|
10
|
+
/** The request/reply protocol version this source speaks. */
|
|
11
|
+
export declare const REQUEST_VERSION = 1;
|
|
12
|
+
/** The subcommand appended to the configured command. */
|
|
13
|
+
export declare const HELPER_SUBCOMMAND = "get";
|
|
14
|
+
/**
|
|
15
|
+
* One credential as the helper reported it. `expiresAt` absent means the lease
|
|
16
|
+
* is good for the life of the process; `account` is a label, never a secret.
|
|
17
|
+
*/
|
|
18
|
+
export interface Lease {
|
|
19
|
+
key: string;
|
|
20
|
+
expiresAt?: number;
|
|
21
|
+
account?: string;
|
|
22
|
+
}
|
|
23
|
+
/** What a helper's stdout said. */
|
|
24
|
+
export type ParsedReply = {
|
|
25
|
+
kind: "credential";
|
|
26
|
+
lease: Lease;
|
|
27
|
+
} | {
|
|
28
|
+
kind: "decline";
|
|
29
|
+
} | {
|
|
30
|
+
kind: "invalid";
|
|
31
|
+
detail: string;
|
|
32
|
+
};
|
|
33
|
+
/** The one JSON line written to the helper's stdin. */
|
|
34
|
+
export declare function requestLine(providerId: string): string;
|
|
35
|
+
/** Validate one credential or decline reply, fail-closed. */
|
|
36
|
+
export declare function parseReply(stdout: string): ParsedReply;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The exec helper's wire contract (US-003): one request line in, one reply line
|
|
3
|
+
* out.
|
|
4
|
+
*
|
|
5
|
+
* Held apart from the process boundary (helper-process.ts) and the source itself
|
|
6
|
+
* (exec-source.ts) because this is the part koda implements against, and because
|
|
7
|
+
* every rule here is fail-closed: anything this module cannot name with certainty
|
|
8
|
+
* is invalid, never a best-effort read of the nearest field.
|
|
9
|
+
*/
|
|
10
|
+
/** The request/reply protocol version this source speaks. */
|
|
11
|
+
export const REQUEST_VERSION = 1;
|
|
12
|
+
/** The subcommand appended to the configured command. */
|
|
13
|
+
export const HELPER_SUBCOMMAND = "get";
|
|
14
|
+
/** A longer credential is a malformed reply, not a key to try. */
|
|
15
|
+
const MAX_KEY_CHARS = 8_192;
|
|
16
|
+
/** The non-secret account label's length cap. */
|
|
17
|
+
const MAX_ACCOUNT_CHARS = 200;
|
|
18
|
+
/** The one JSON line written to the helper's stdin. */
|
|
19
|
+
export function requestLine(providerId) {
|
|
20
|
+
return `${JSON.stringify({ version: REQUEST_VERSION, providerId })}\n`;
|
|
21
|
+
}
|
|
22
|
+
/** Validate one credential or decline reply, fail-closed. */
|
|
23
|
+
export function parseReply(stdout) {
|
|
24
|
+
const invalid = (detail) => ({ kind: "invalid", detail });
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = JSON.parse(stdout);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// The parser's message quotes the text it failed on, so it carries a fragment
|
|
31
|
+
// of stdout — which is where the key lives. The reply is malformed either
|
|
32
|
+
// way, and the caller has the code; naming the parser's text would put a
|
|
33
|
+
// secret in an error message, and from there in the CLI and the precheck
|
|
34
|
+
// report. See the Secrets rule in docs/specs/SPEC-credential-sources.md.
|
|
35
|
+
return invalid("stdout was not JSON");
|
|
36
|
+
}
|
|
37
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
38
|
+
return invalid("the reply was not a JSON object");
|
|
39
|
+
}
|
|
40
|
+
const reply = parsed;
|
|
41
|
+
// Neither `version` nor `kind` is echoed back: both are helper-controlled and
|
|
42
|
+
// unvalidated, so `JSON.stringify` on one would copy an arbitrarily large
|
|
43
|
+
// subtree of stdout — which is where the key is — into the message. Naming the
|
|
44
|
+
// field and the expected value is enough to debug a helper.
|
|
45
|
+
if (reply.version !== REQUEST_VERSION) {
|
|
46
|
+
return invalid(`version was not ${REQUEST_VERSION}`);
|
|
47
|
+
}
|
|
48
|
+
// The decline reply carries no kind, so it is recognised before the credential
|
|
49
|
+
// fields are required.
|
|
50
|
+
if (reply.decline === true)
|
|
51
|
+
return { kind: "decline" };
|
|
52
|
+
if (reply.kind !== "api-key")
|
|
53
|
+
return invalid('kind was not "api-key"');
|
|
54
|
+
const key = reply.key;
|
|
55
|
+
if (typeof key !== "string" || key.length === 0)
|
|
56
|
+
return invalid("the reply carried no key");
|
|
57
|
+
if (key.length > MAX_KEY_CHARS)
|
|
58
|
+
return invalid(`the key was longer than ${MAX_KEY_CHARS} characters`);
|
|
59
|
+
const account = reply.account;
|
|
60
|
+
if (account !== undefined && (typeof account !== "string" || account.length > MAX_ACCOUNT_CHARS)) {
|
|
61
|
+
return invalid(`the account label was not a string of at most ${MAX_ACCOUNT_CHARS} characters`);
|
|
62
|
+
}
|
|
63
|
+
const expiresAt = reply.expiresAt;
|
|
64
|
+
if (expiresAt !== undefined) {
|
|
65
|
+
if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt <= 0) {
|
|
66
|
+
return invalid("expiresAt was not a positive integer");
|
|
67
|
+
}
|
|
68
|
+
if (expiresAt < Date.now())
|
|
69
|
+
return invalid("expiresAt was already past on receipt");
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
kind: "credential",
|
|
73
|
+
lease: {
|
|
74
|
+
key,
|
|
75
|
+
...(typeof account === "string" ? { account } : {}),
|
|
76
|
+
...(typeof expiresAt === "number" ? { expiresAt } : {}),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|