@vellumai/assistant 0.8.11 → 0.8.12-staging.2
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/ARCHITECTURE.md +15 -17
- package/README.md +0 -6
- package/bun.lock +6 -122
- package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
- package/node_modules/@vellumai/gateway-client/package.json +3 -1
- package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
- package/openapi.yaml +633 -4
- package/package.json +1 -3
- package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
- package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
- package/src/__tests__/anthropic-provider.test.ts +129 -0
- package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
- package/src/__tests__/btw-routes.test.ts +7 -34
- package/src/__tests__/checker.test.ts +6 -12
- package/src/__tests__/config-loader-backfill.test.ts +4 -2
- package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
- package/src/__tests__/config-watcher.test.ts +2 -2
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +2 -6
- package/src/__tests__/conversation-history-web-search.test.ts +8 -0
- package/src/__tests__/conversation-title-service.test.ts +2 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/disk-pressure-tools.test.ts +1 -1
- package/src/__tests__/exploration-drift-hook.test.ts +692 -0
- package/src/__tests__/filing-service.test.ts +8 -3
- package/src/__tests__/guardian-action-store.test.ts +0 -167
- package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
- package/src/__tests__/heartbeat-service.test.ts +5 -2
- package/src/__tests__/identity-intro-cache.test.ts +12 -5
- package/src/__tests__/identity-routes.test.ts +16 -57
- package/src/__tests__/injector-chain.test.ts +8 -3
- package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
- package/src/__tests__/llm-catalog-parity.test.ts +16 -0
- package/src/__tests__/llm-usage-store.test.ts +11 -0
- package/src/__tests__/log-export-workspace.test.ts +468 -3
- package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
- package/src/__tests__/model-intents.test.ts +1 -1
- package/src/__tests__/oauth-cli.test.ts +19 -8
- package/src/__tests__/openai-provider.test.ts +34 -0
- package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
- package/src/__tests__/recurrence-engine.test.ts +45 -0
- package/src/__tests__/schedule-routes.test.ts +34 -0
- package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
- package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
- package/src/__tests__/secret-fixtures.ts +20 -0
- package/src/__tests__/skill-tool-factory.test.ts +49 -0
- package/src/__tests__/subagent-role-registry.test.ts +24 -1
- package/src/__tests__/subagent-tools.test.ts +1 -0
- package/src/__tests__/system-prompt.test.ts +109 -11
- package/src/__tests__/tool-approval-handler.test.ts +85 -0
- package/src/__tests__/tool-audit-listener.test.ts +86 -0
- package/src/__tests__/tool-error-hook.test.ts +1 -0
- package/src/__tests__/tool-result-spool.test.ts +337 -0
- package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
- package/src/__tests__/validate-input.test.ts +95 -1
- package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
- package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
- package/src/__tests__/workspace-migration-100-upgrade-quality-profile-to-fable-5.test.ts +174 -0
- package/src/__tests__/workspace-migration-101-upgrade-balanced-economy-to-minimax-m3.test.ts +162 -0
- package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
- package/src/acp/__tests__/agent-process.test.ts +315 -2
- package/src/acp/__tests__/prepare-agent-env.test.ts +79 -5
- package/src/acp/agent-process.ts +163 -34
- package/src/acp/prepare-agent-env.ts +55 -15
- package/src/agent/loop.ts +81 -24
- package/src/api/events/usage-progress.ts +28 -0
- package/src/api/index.ts +6 -0
- package/src/background-wake/wake-intent-hooks.test.ts +2 -0
- package/src/bundler/app-bundler.ts +25 -42
- package/src/bundler/app-compiler.ts +8 -0
- package/src/calls/call-controller.ts +1 -1
- package/src/cli/commands/plugins.ts +248 -15
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
- package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
- package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
- package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
- package/src/cli/lib/__tests__/upgrade-plugin.test.ts +307 -0
- package/src/cli/lib/inspect-plugin.ts +252 -0
- package/src/cli/lib/install-from-github.ts +214 -21
- package/src/cli/lib/list-installed-plugins.ts +17 -6
- package/src/cli/lib/plugin-artifact.ts +103 -0
- package/src/cli/lib/plugin-details.ts +18 -1
- package/src/cli/lib/plugin-fingerprint.ts +197 -0
- package/src/cli/lib/upgrade-plugin.ts +225 -0
- package/src/config/bundled-skills/subagent/SKILL.md +2 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
- package/src/config/call-site-defaults.ts +13 -2
- package/src/config/feature-flag-registry.json +8 -16
- package/src/config/loader.ts +52 -59
- package/src/config/schema.ts +0 -2
- package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
- package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
- package/src/config/schemas/llm.ts +10 -0
- package/src/config/schemas/memory-v2.ts +13 -0
- package/src/config/schemas/memory-v3.ts +92 -0
- package/src/config/seed-inference-profiles.ts +4 -8
- package/src/context/post-turn-tool-result-truncation.ts +32 -18
- package/src/context/tool-result-spool.ts +104 -0
- package/src/credential-execution/feature-gates.ts +0 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
- package/src/daemon/conversation-error.ts +6 -15
- package/src/daemon/conversation.ts +9 -0
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +1 -20
- package/src/daemon/message-types/conversations.ts +2 -15
- package/src/daemon/trust-context.ts +1 -1
- package/src/events/tool-audit-listener.ts +40 -9
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
- package/src/home/__tests__/home-content-refresh.test.ts +114 -0
- package/src/home/__tests__/suggested-prompts.test.ts +86 -5
- package/src/home/home-content-refresh.ts +43 -31
- package/src/home/home-greeting-cache.ts +8 -1
- package/src/home/home-greeting.ts +13 -9
- package/src/home/suggested-prompts.ts +77 -24
- package/src/ipc/routes/trust-rules.test.ts +66 -72
- package/src/media/image-credentials.ts +2 -2
- package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
- package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
- package/src/memory/conversation-attention-store.ts +1 -0
- package/src/memory/conversation-bootstrap.ts +18 -9
- package/src/memory/conversation-crud.ts +12 -2
- package/src/memory/conversation-title-service.ts +53 -9
- package/src/memory/delivery-channels.ts +0 -69
- package/src/memory/graph/extraction-job.ts +0 -15
- package/src/memory/guardian-action-store.ts +1 -376
- package/src/memory/llm-usage-store.ts +5 -1
- package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
- package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
- package/src/memory/v2/__tests__/injection.test.ts +70 -0
- package/src/memory/v2/__tests__/static-context.test.ts +12 -0
- package/src/memory/v2/consolidation-job.ts +93 -9
- package/src/memory/v2/injection.ts +53 -0
- package/src/memory/v2/prompts/consolidation.ts +1 -0
- package/src/memory/v2/static-context.ts +13 -1
- package/src/memory/v2/sweep-job.ts +1 -1
- package/src/memory/v2/types.ts +5 -0
- package/src/plugin-api/types.ts +7 -0
- package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
- package/src/plugins/defaults/exploration-drift/package.json +15 -0
- package/src/plugins/defaults/index.ts +25 -0
- package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
- package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
- package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
- package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
- package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
- package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
- package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
- package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
- package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
- package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
- package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
- package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
- package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
- package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
- package/src/prompts/cache-boundary.ts +17 -0
- package/src/prompts/sections.ts +50 -17
- package/src/prompts/system-prompt.ts +12 -4
- package/src/prompts/templates/system-sections.ts +22 -0
- package/src/providers/__tests__/unparseable-tool-args.test.ts +53 -0
- package/src/providers/anthropic/client.ts +74 -28
- package/src/providers/gemini/client.ts +5 -1
- package/src/providers/minimax/client.ts +9 -0
- package/src/providers/model-catalog.ts +28 -0
- package/src/providers/model-intents.ts +3 -3
- package/src/providers/openai/chat-completions-provider.ts +4 -2
- package/src/providers/openai/responses-provider.ts +7 -2
- package/src/providers/retry.ts +8 -0
- package/src/providers/types.ts +11 -0
- package/src/providers/unparseable-tool-args.ts +56 -0
- package/src/runtime/AGENTS.md +6 -0
- package/src/runtime/__tests__/agent-wake.test.ts +2 -2
- package/src/runtime/agent-wake.ts +5 -5
- package/src/runtime/background-job-runner.ts +2 -2
- package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
- package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
- package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
- package/src/runtime/migrations/vbundle-importer.ts +9 -4
- package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
- package/src/runtime/pre-first-message-gate.ts +1 -1
- package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
- package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +132 -0
- package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
- package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +348 -0
- package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
- package/src/runtime/routes/btw-routes.ts +0 -14
- package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
- package/src/runtime/routes/conversation-list-routes.ts +77 -5
- package/src/runtime/routes/conversation-management-routes.ts +54 -0
- package/src/runtime/routes/conversation-query-routes.ts +79 -4
- package/src/runtime/routes/gateway-log-routes.ts +14 -64
- package/src/runtime/routes/home-feed-routes.ts +10 -0
- package/src/runtime/routes/identity-intro-cache.ts +1 -1
- package/src/runtime/routes/identity-routes.ts +76 -20
- package/src/runtime/routes/inbound-message-handler.ts +0 -36
- package/src/runtime/routes/log-export-routes.ts +143 -96
- package/src/runtime/routes/plugins-routes.ts +380 -0
- package/src/runtime/routes/redact-staged-export.ts +259 -0
- package/src/runtime/routes/schedule-routes.ts +19 -2
- package/src/runtime/routes/trust-rules-routes.ts +14 -67
- package/src/schedule/recurrence-engine.ts +34 -0
- package/src/schedule/scheduler.ts +1 -0
- package/src/security/redact-json.ts +61 -0
- package/src/skills/validate-input.ts +41 -1
- package/src/subagent/types.ts +26 -1
- package/src/telemetry/types.ts +15 -1
- package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
- package/src/telemetry/usage-telemetry-reporter.ts +1 -0
- package/src/tools/apps/executors.ts +1 -1
- package/src/tools/skills/skill-tool-factory.ts +19 -8
- package/src/tools/tool-approval-handler.ts +31 -0
- package/src/usage/types.ts +8 -1
- package/src/util/platform.ts +16 -0
- package/src/watcher/engine.ts +1 -0
- package/src/workspace/adaptive-thinking-repair.ts +113 -0
- package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
- package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
- package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
- package/src/workspace/migrations/100-upgrade-quality-profile-to-fable-5.ts +86 -0
- package/src/workspace/migrations/101-upgrade-balanced-economy-to-minimax-m3.ts +70 -0
- package/src/workspace/migrations/registry.ts +8 -0
- package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
- package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
- package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
- package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
- package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
- package/src/__tests__/update-bulletin-job.test.ts +0 -292
- package/src/config/schemas/updates.ts +0 -14
- package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
- package/src/memory/conversation-starters-cadence.ts +0 -78
- package/src/prompts/update-bulletin-job.ts +0 -180
- package/src/runtime/guardian-action-followup-executor.ts +0 -306
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
|
|
26
26
|
import { z } from "zod";
|
|
27
27
|
|
|
28
|
+
import {
|
|
29
|
+
inspectPlugin,
|
|
30
|
+
PluginInspectNotFoundError,
|
|
31
|
+
} from "../../cli/lib/inspect-plugin.js";
|
|
28
32
|
import {
|
|
29
33
|
DEFAULT_PLUGIN_REF,
|
|
30
34
|
installPlugin,
|
|
@@ -53,6 +57,10 @@ import {
|
|
|
53
57
|
PluginNotInstalledError,
|
|
54
58
|
uninstallPlugin,
|
|
55
59
|
} from "../../cli/lib/uninstall-plugin.js";
|
|
60
|
+
import {
|
|
61
|
+
PluginNotUpgradableError,
|
|
62
|
+
upgradePlugin,
|
|
63
|
+
} from "../../cli/lib/upgrade-plugin.js";
|
|
56
64
|
import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
|
|
57
65
|
import {
|
|
58
66
|
BadRequestError,
|
|
@@ -191,6 +199,27 @@ const pluginDetailsResponseSchema = z.object({
|
|
|
191
199
|
ref: z
|
|
192
200
|
.string()
|
|
193
201
|
.describe("Git ref the catalog metadata / README were resolved at."),
|
|
202
|
+
artifact: z
|
|
203
|
+
.object({
|
|
204
|
+
url: z
|
|
205
|
+
.string()
|
|
206
|
+
.describe("HTTPS URL the prebuilt client artifact is downloaded from."),
|
|
207
|
+
sha256: z
|
|
208
|
+
.string()
|
|
209
|
+
.describe(
|
|
210
|
+
"Lowercase 64-char hex SHA-256 the download is verified against.",
|
|
211
|
+
),
|
|
212
|
+
label: z
|
|
213
|
+
.string()
|
|
214
|
+
.optional()
|
|
215
|
+
.describe(
|
|
216
|
+
'Optional human label for the download (e.g. "Download for macOS"); absent when the plugin doesn\'t name it.',
|
|
217
|
+
),
|
|
218
|
+
})
|
|
219
|
+
.nullable()
|
|
220
|
+
.describe(
|
|
221
|
+
"Prebuilt client artifact from `package.json` `vellum.artifact`, or null when the plugin ships none or its descriptor is incomplete (e.g. a placeholder sha256).",
|
|
222
|
+
),
|
|
194
223
|
});
|
|
195
224
|
|
|
196
225
|
const pluginInstallRequestSchema = z.object({
|
|
@@ -215,6 +244,197 @@ const pluginInstallResponseSchema = z.object({
|
|
|
215
244
|
ref: z.string().describe("Git ref the plugin was fetched from."),
|
|
216
245
|
});
|
|
217
246
|
|
|
247
|
+
const fingerprintComparisonSchema = z
|
|
248
|
+
.object({
|
|
249
|
+
modified: z
|
|
250
|
+
.array(z.string())
|
|
251
|
+
.describe("Tracked files whose content changed since install."),
|
|
252
|
+
added: z
|
|
253
|
+
.array(z.string())
|
|
254
|
+
.describe("Files present on disk but absent from the install baseline."),
|
|
255
|
+
removed: z
|
|
256
|
+
.array(z.string())
|
|
257
|
+
.describe("Files recorded at install but missing from the on-disk copy."),
|
|
258
|
+
clean: z
|
|
259
|
+
.boolean()
|
|
260
|
+
.describe("True when no files were added, removed, or modified."),
|
|
261
|
+
})
|
|
262
|
+
.describe(
|
|
263
|
+
"Local-edit comparison of the on-disk tree against the install-time fingerprint.",
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const installMetaSourceSchema = z
|
|
267
|
+
.object({
|
|
268
|
+
kind: z.string().describe("Source kind. Only `github` is written today."),
|
|
269
|
+
owner: z.string(),
|
|
270
|
+
repo: z.string(),
|
|
271
|
+
path: z
|
|
272
|
+
.string()
|
|
273
|
+
.optional()
|
|
274
|
+
.describe(
|
|
275
|
+
"Repo-relative directory holding the plugin root; absent = repo root.",
|
|
276
|
+
),
|
|
277
|
+
ref: z
|
|
278
|
+
.string()
|
|
279
|
+
.describe(
|
|
280
|
+
"Ref the install resolved through (the pinned commit SHA for marketplace installs).",
|
|
281
|
+
),
|
|
282
|
+
})
|
|
283
|
+
.describe(
|
|
284
|
+
"Source coordinates recorded in the install-time provenance sidecar.",
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const pluginLocalInfoSchema = z
|
|
288
|
+
.object({
|
|
289
|
+
target: z
|
|
290
|
+
.string()
|
|
291
|
+
.describe("Absolute path to the installed plugin directory."),
|
|
292
|
+
commit: z
|
|
293
|
+
.string()
|
|
294
|
+
.nullable()
|
|
295
|
+
.describe(
|
|
296
|
+
"Resolved commit the copy was installed at; null when no provenance was recorded.",
|
|
297
|
+
),
|
|
298
|
+
version: z
|
|
299
|
+
.string()
|
|
300
|
+
.nullable()
|
|
301
|
+
.describe("Installed `package.json#version`."),
|
|
302
|
+
description: z
|
|
303
|
+
.string()
|
|
304
|
+
.nullable()
|
|
305
|
+
.describe("Installed `package.json#description`."),
|
|
306
|
+
installedAt: z
|
|
307
|
+
.string()
|
|
308
|
+
.nullable()
|
|
309
|
+
.describe(
|
|
310
|
+
"ISO-8601 install timestamp from the sidecar; null when absent.",
|
|
311
|
+
),
|
|
312
|
+
source: installMetaSourceSchema
|
|
313
|
+
.nullable()
|
|
314
|
+
.describe(
|
|
315
|
+
"Source recorded at install time; null when no sidecar exists.",
|
|
316
|
+
),
|
|
317
|
+
localChanges: fingerprintComparisonSchema
|
|
318
|
+
.nullable()
|
|
319
|
+
.describe(
|
|
320
|
+
"Local-edit state vs the install-time fingerprint; null when no baseline was recorded (older/manual install).",
|
|
321
|
+
),
|
|
322
|
+
issues: z
|
|
323
|
+
.array(z.string())
|
|
324
|
+
.describe(
|
|
325
|
+
"Non-fatal issues with the installed copy (e.g. malformed `package.json`).",
|
|
326
|
+
),
|
|
327
|
+
})
|
|
328
|
+
.describe("The locally installed copy of the plugin.");
|
|
329
|
+
|
|
330
|
+
const pluginRemoteInfoSchema = z
|
|
331
|
+
.object({
|
|
332
|
+
repo: z
|
|
333
|
+
.string()
|
|
334
|
+
.describe("`owner/repo` of the external plugin repository."),
|
|
335
|
+
path: z
|
|
336
|
+
.string()
|
|
337
|
+
.describe(
|
|
338
|
+
'Repo-relative directory holding the plugin root; `""` = repo root.',
|
|
339
|
+
),
|
|
340
|
+
commit: z
|
|
341
|
+
.string()
|
|
342
|
+
.describe(
|
|
343
|
+
"Pinned commit SHA the marketplace currently resolves installs to.",
|
|
344
|
+
),
|
|
345
|
+
description: z.string().nullable(),
|
|
346
|
+
homepage: z.string().nullable(),
|
|
347
|
+
license: z.string().nullable(),
|
|
348
|
+
category: z.string().nullable(),
|
|
349
|
+
marketplaceRef: z
|
|
350
|
+
.string()
|
|
351
|
+
.describe(
|
|
352
|
+
"Ref of the canonical repo the marketplace manifest was read from.",
|
|
353
|
+
),
|
|
354
|
+
})
|
|
355
|
+
.describe("The marketplace's current pin and advertised metadata.");
|
|
356
|
+
|
|
357
|
+
const pluginInspectResponseSchema = z.object({
|
|
358
|
+
name: z
|
|
359
|
+
.string()
|
|
360
|
+
.describe("Install name. Matches `assistant plugins install <name>`."),
|
|
361
|
+
installed: z
|
|
362
|
+
.boolean()
|
|
363
|
+
.describe(
|
|
364
|
+
"Whether a copy is materialized under `<workspaceDir>/plugins/`.",
|
|
365
|
+
),
|
|
366
|
+
status: z
|
|
367
|
+
.enum([
|
|
368
|
+
"up-to-date",
|
|
369
|
+
"update-available",
|
|
370
|
+
"not-installed",
|
|
371
|
+
"not-in-marketplace",
|
|
372
|
+
"unknown-provenance",
|
|
373
|
+
"remote-unavailable",
|
|
374
|
+
])
|
|
375
|
+
.describe(
|
|
376
|
+
"Drift classification between the installed copy and the marketplace pin.",
|
|
377
|
+
),
|
|
378
|
+
local: pluginLocalInfoSchema
|
|
379
|
+
.nullable()
|
|
380
|
+
.describe("Locally installed copy; null when the plugin is not installed."),
|
|
381
|
+
remote: pluginRemoteInfoSchema
|
|
382
|
+
.nullable()
|
|
383
|
+
.describe(
|
|
384
|
+
"Marketplace pin + metadata; null when no entry claims the name or it was unreachable.",
|
|
385
|
+
),
|
|
386
|
+
remoteError: z
|
|
387
|
+
.string()
|
|
388
|
+
.nullable()
|
|
389
|
+
.describe(
|
|
390
|
+
"Marketplace fetch error message, when the catalog could not be read.",
|
|
391
|
+
),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const pluginUpgradeRequestSchema = z.object({
|
|
395
|
+
dryRun: z
|
|
396
|
+
.boolean()
|
|
397
|
+
.optional()
|
|
398
|
+
.describe(
|
|
399
|
+
"Report what would change without modifying the install. Defaults to false.",
|
|
400
|
+
),
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
const pluginUpgradeResponseSchema = z.object({
|
|
404
|
+
name: z.string().describe("Install name that was (or would be) upgraded."),
|
|
405
|
+
outcome: z
|
|
406
|
+
.enum(["upgraded", "already-up-to-date", "would-upgrade"])
|
|
407
|
+
.describe(
|
|
408
|
+
"`upgraded` moved the install to the pin; `already-up-to-date` was a no-op; `would-upgrade` is a dry-run that found drift.",
|
|
409
|
+
),
|
|
410
|
+
fromCommit: z
|
|
411
|
+
.string()
|
|
412
|
+
.nullable()
|
|
413
|
+
.describe(
|
|
414
|
+
"Installed commit before the upgrade; null when no provenance was recorded.",
|
|
415
|
+
),
|
|
416
|
+
toCommit: z
|
|
417
|
+
.string()
|
|
418
|
+
.describe(
|
|
419
|
+
"Marketplace-pinned commit the install was (or would be) moved to.",
|
|
420
|
+
),
|
|
421
|
+
target: z
|
|
422
|
+
.string()
|
|
423
|
+
.describe("Absolute path to the installed plugin directory on the host."),
|
|
424
|
+
fileCount: z
|
|
425
|
+
.number()
|
|
426
|
+
.nullable()
|
|
427
|
+
.describe(
|
|
428
|
+
"Files materialized by the upgrade; null for a no-op or dry run.",
|
|
429
|
+
),
|
|
430
|
+
dryRun: z.boolean().describe("Whether this was a dry run (no changes made)."),
|
|
431
|
+
provenanceWasUnknown: z
|
|
432
|
+
.boolean()
|
|
433
|
+
.describe(
|
|
434
|
+
"Whether the install lacked resolvable provenance before the upgrade; such installs are re-pinned to record it going forward.",
|
|
435
|
+
),
|
|
436
|
+
});
|
|
437
|
+
|
|
218
438
|
// ---------------------------------------------------------------------------
|
|
219
439
|
// Helpers
|
|
220
440
|
// ---------------------------------------------------------------------------
|
|
@@ -468,6 +688,91 @@ async function handleInstallPlugin({ body = {} }: RouteHandlerArgs) {
|
|
|
468
688
|
}
|
|
469
689
|
}
|
|
470
690
|
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
// Handler — inspect (drift)
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
async function handleInspectPlugin({ pathParams = {} }: RouteHandlerArgs) {
|
|
696
|
+
const rawName = pathParams.name ?? "";
|
|
697
|
+
|
|
698
|
+
try {
|
|
699
|
+
// `inspectPlugin` never throws for an unreachable marketplace when a local
|
|
700
|
+
// copy exists — it reports `status: "remote-unavailable"` and captures the
|
|
701
|
+
// message in `remoteError`. It only throws when there is nothing to show.
|
|
702
|
+
return await inspectPlugin(
|
|
703
|
+
{ name: rawName },
|
|
704
|
+
{ fetch: globalThis.fetch.bind(globalThis) },
|
|
705
|
+
);
|
|
706
|
+
} catch (err) {
|
|
707
|
+
if (err instanceof InvalidPluginNameError) {
|
|
708
|
+
throw new BadRequestError(err.message);
|
|
709
|
+
}
|
|
710
|
+
if (err instanceof PluginInspectNotFoundError) {
|
|
711
|
+
throw new NotFoundError(err.message);
|
|
712
|
+
}
|
|
713
|
+
throw new InternalError(
|
|
714
|
+
err instanceof Error ? err.message : "plugin inspect failed",
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ---------------------------------------------------------------------------
|
|
720
|
+
// Handler — upgrade
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
|
|
723
|
+
async function handleUpgradePlugin({
|
|
724
|
+
pathParams = {},
|
|
725
|
+
body = {},
|
|
726
|
+
}: RouteHandlerArgs) {
|
|
727
|
+
const rawName = pathParams.name ?? "";
|
|
728
|
+
const dryRun = typeof body.dryRun === "boolean" ? body.dryRun : undefined;
|
|
729
|
+
|
|
730
|
+
// Like install, the upgrade target ref is the curated marketplace pin
|
|
731
|
+
// (resolved inside `upgradePlugin` via `inspectPlugin`), never a
|
|
732
|
+
// caller-supplied ref — a `settings.write` principal cannot redirect the
|
|
733
|
+
// upgrade at an unreviewed revision.
|
|
734
|
+
try {
|
|
735
|
+
const result = await upgradePlugin(
|
|
736
|
+
{ name: rawName, dryRun },
|
|
737
|
+
{ fetch: globalThis.fetch.bind(globalThis) },
|
|
738
|
+
);
|
|
739
|
+
return {
|
|
740
|
+
name: result.name,
|
|
741
|
+
outcome: result.outcome,
|
|
742
|
+
fromCommit: result.fromCommit,
|
|
743
|
+
toCommit: result.toCommit,
|
|
744
|
+
target: result.target,
|
|
745
|
+
fileCount: result.fileCount,
|
|
746
|
+
dryRun: result.dryRun,
|
|
747
|
+
provenanceWasUnknown: result.provenanceWasUnknown,
|
|
748
|
+
};
|
|
749
|
+
} catch (err) {
|
|
750
|
+
if (err instanceof InvalidPluginNameError) {
|
|
751
|
+
throw new BadRequestError(err.message);
|
|
752
|
+
}
|
|
753
|
+
if (err instanceof PluginNotInstalledError) {
|
|
754
|
+
throw new NotFoundError(err.message);
|
|
755
|
+
}
|
|
756
|
+
if (err instanceof PluginNotFoundError) {
|
|
757
|
+
throw new NotFoundError(err.message);
|
|
758
|
+
}
|
|
759
|
+
// The install exists but has no marketplace entry to advance to — a
|
|
760
|
+
// permanent state the caller cannot resolve by retrying. 409 marks the
|
|
761
|
+
// request as well-formed but not actionable in the current state.
|
|
762
|
+
if (err instanceof PluginNotUpgradableError) {
|
|
763
|
+
throw new ConflictError(err.message);
|
|
764
|
+
}
|
|
765
|
+
// A rate-limited or temporarily-down source (the plugin repo or the
|
|
766
|
+
// marketplace catalog) is a retryable outage, not a conflict — 503.
|
|
767
|
+
if (err instanceof PluginSourceUnavailableError) {
|
|
768
|
+
throw new ServiceUnavailableError(err.message);
|
|
769
|
+
}
|
|
770
|
+
throw new InternalError(
|
|
771
|
+
err instanceof Error ? err.message : "plugin upgrade failed",
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
471
776
|
// ---------------------------------------------------------------------------
|
|
472
777
|
// Route definitions
|
|
473
778
|
// ---------------------------------------------------------------------------
|
|
@@ -628,4 +933,79 @@ export const ROUTES: RouteDefinition[] = [
|
|
|
628
933
|
},
|
|
629
934
|
handler: handleUninstallPlugin,
|
|
630
935
|
},
|
|
936
|
+
{
|
|
937
|
+
operationId: "plugins_inspect",
|
|
938
|
+
endpoint: "plugins/:name/inspect",
|
|
939
|
+
method: "GET",
|
|
940
|
+
policy: {
|
|
941
|
+
requiredScopes: ["settings.read"],
|
|
942
|
+
allowedPrincipalTypes: ACTOR_PRINCIPALS,
|
|
943
|
+
},
|
|
944
|
+
summary: "Inspect a plugin's install drift",
|
|
945
|
+
description:
|
|
946
|
+
"Compare the locally installed copy of a plugin against the marketplace's current pinned commit and report whether an upgrade is available. Returns a six-way `status` (`up-to-date`, `update-available`, `not-installed`, `not-in-marketplace`, `unknown-provenance`, `remote-unavailable`) plus the local provenance (installed commit, version, source, and any local edits vs the install-time fingerprint) and the remote pin. An unreachable marketplace for an installed plugin is not fatal — it returns 200 with `status: \"remote-unavailable\"`. A name that is neither installed nor in the catalog returns 404. Powers the web upgrade affordance; mirrors the CLI's `assistant plugins inspect <name>` and `GET /v1/skills/:id/inspect`.",
|
|
947
|
+
tags: ["plugins"],
|
|
948
|
+
pathParams: [
|
|
949
|
+
{
|
|
950
|
+
name: "name",
|
|
951
|
+
type: "string",
|
|
952
|
+
description:
|
|
953
|
+
"Install name. Must match the kebab-case name accepted by `assistant plugins install`.",
|
|
954
|
+
},
|
|
955
|
+
],
|
|
956
|
+
responseBody: pluginInspectResponseSchema,
|
|
957
|
+
additionalResponses: {
|
|
958
|
+
"400": {
|
|
959
|
+
description:
|
|
960
|
+
"The plugin name failed sanitization (e.g. contained slashes, dots, or uppercase letters).",
|
|
961
|
+
},
|
|
962
|
+
"404": {
|
|
963
|
+
description:
|
|
964
|
+
"No installed copy and no catalog entry claims the given name.",
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
handler: handleInspectPlugin,
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
operationId: "plugins_upgrade",
|
|
971
|
+
endpoint: "plugins/:name/upgrade",
|
|
972
|
+
method: "POST",
|
|
973
|
+
policy: {
|
|
974
|
+
requiredScopes: ["settings.write"],
|
|
975
|
+
allowedPrincipalTypes: ACTOR_PRINCIPALS,
|
|
976
|
+
},
|
|
977
|
+
summary: "Upgrade a plugin to the marketplace pin",
|
|
978
|
+
description:
|
|
979
|
+
'Move an installed plugin to the marketplace\'s current pinned commit, re-materializing it under `<workspaceDir>/plugins/<name>/`. Always resolves against the curated marketplace pin (no caller-supplied ref), mirroring `plugins install`\'s curation boundary. A no-op (`outcome: "already-up-to-date"`) when the installed commit already equals the pin; pass `dryRun` to preview the move (`outcome: "would-upgrade"`) without touching the install. Installs lacking provenance are re-pinned to the current SHA. The assistant must be restarted to load the upgraded code. This does not gate on local edits — callers should consult `GET /v1/plugins/:name/inspect` (`local.localChanges`) first and confirm before overwriting. Mirrors the CLI\'s `assistant plugins upgrade <name>`.',
|
|
980
|
+
tags: ["plugins"],
|
|
981
|
+
pathParams: [
|
|
982
|
+
{
|
|
983
|
+
name: "name",
|
|
984
|
+
type: "string",
|
|
985
|
+
description:
|
|
986
|
+
"Install name. Must match the kebab-case name accepted by `assistant plugins install`.",
|
|
987
|
+
},
|
|
988
|
+
],
|
|
989
|
+
requestBody: pluginUpgradeRequestSchema,
|
|
990
|
+
responseBody: pluginUpgradeResponseSchema,
|
|
991
|
+
additionalResponses: {
|
|
992
|
+
"400": {
|
|
993
|
+
description:
|
|
994
|
+
"The plugin name failed sanitization (e.g. contained slashes, dots, or uppercase letters).",
|
|
995
|
+
},
|
|
996
|
+
"404": {
|
|
997
|
+
description:
|
|
998
|
+
"No copy of the plugin is installed, or its source resolves to nothing.",
|
|
999
|
+
},
|
|
1000
|
+
"409": {
|
|
1001
|
+
description:
|
|
1002
|
+
"The install exists but has no marketplace entry to advance to.",
|
|
1003
|
+
},
|
|
1004
|
+
"503": {
|
|
1005
|
+
description:
|
|
1006
|
+
"The plugin source (GitHub) was temporarily unavailable; the upgrade is retryable.",
|
|
1007
|
+
},
|
|
1008
|
+
},
|
|
1009
|
+
handler: handleUpgradePlugin,
|
|
1010
|
+
},
|
|
631
1011
|
];
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export-time secret sweep for the log export staging directory.
|
|
3
|
+
*
|
|
4
|
+
* `POST /v1/export` stages audit DB rows, daemon logs, a sanitized config
|
|
5
|
+
* snapshot, and allowlisted workspace files before archiving them. The
|
|
6
|
+
* structural sanitizers (write-time audit-input redaction, config snapshot
|
|
7
|
+
* env scrubbing) only protect data written after they shipped — legacy audit
|
|
8
|
+
* rows already persisted with plaintext inputs, and free-form workspace
|
|
9
|
+
* conversation logs, can still carry raw secrets. This module is the final
|
|
10
|
+
* belt: a pattern-based `redactSecrets()` pass over staged files immediately
|
|
11
|
+
* before the tar.gz is created.
|
|
12
|
+
*
|
|
13
|
+
* Coverage: every staged file that sniffs as text (no NUL byte in its first
|
|
14
|
+
* 8 KiB) is swept; files that sniff as binary are left untouched. That
|
|
15
|
+
* single check covers everything the export stages as text today —
|
|
16
|
+
* `audit-data.json`, `messages.json`, `config-snapshot.json`, daemon `.log`
|
|
17
|
+
* files, `conversation-filtered.jsonl`, workspace copies
|
|
18
|
+
* (`conversations/**\/messages.jsonl`, `.tool-results/*.txt`) — plus
|
|
19
|
+
* conversation `attachments/` with arbitrary user extensions (`.env`,
|
|
20
|
+
* `.csv`, extensionless). Serialized-JSON formats (`.json`, `.jsonl`) are
|
|
21
|
+
* redacted via a parse → redact-string-leaves → re-stringify path so the
|
|
22
|
+
* quoted redaction marker cannot corrupt them. Files above the size cap are
|
|
23
|
+
* NOT shipped unswept: their content is replaced with a short omission note
|
|
24
|
+
* (fail closed). Likewise, a file whose redacted content cannot be written
|
|
25
|
+
* back is replaced with a note rather than shipped with its original bytes.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { Dirent } from "node:fs";
|
|
29
|
+
import {
|
|
30
|
+
chmodSync,
|
|
31
|
+
closeSync,
|
|
32
|
+
openSync,
|
|
33
|
+
readdirSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
readSync,
|
|
36
|
+
rmSync,
|
|
37
|
+
statSync,
|
|
38
|
+
writeFileSync,
|
|
39
|
+
} from "node:fs";
|
|
40
|
+
import { extname, join } from "node:path";
|
|
41
|
+
|
|
42
|
+
import { redactJsonStringLeaves } from "../../security/redact-json.js";
|
|
43
|
+
import { redactSecrets } from "../../security/secret-scanner.js";
|
|
44
|
+
import { getLogger } from "../../util/logger.js";
|
|
45
|
+
|
|
46
|
+
const log = getLogger("redact-staged-export");
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Files larger than this are not read into memory — the sweep reads each
|
|
50
|
+
* file fully to redact it. Every staged section is bounded at the source
|
|
51
|
+
* (row-capped DB dumps, the daemon-log payload cap, workspace copy caps), so
|
|
52
|
+
* staged files sit well below this. Oversized sweep-eligible files fail
|
|
53
|
+
* closed: their content is replaced with `OVERSIZED_FILE_NOTE` so legacy
|
|
54
|
+
* plaintext secrets can never ship unswept.
|
|
55
|
+
*/
|
|
56
|
+
export const MAX_SWEEP_FILE_BYTES = 32 * 1024 * 1024;
|
|
57
|
+
|
|
58
|
+
/** Replacement content for files that exceed `MAX_SWEEP_FILE_BYTES`. */
|
|
59
|
+
export const OVERSIZED_FILE_NOTE = `[content omitted from export: file exceeded the ${
|
|
60
|
+
MAX_SWEEP_FILE_BYTES / (1024 * 1024)
|
|
61
|
+
} MiB secret-redaction cap]\n`;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Replacement content for files where redaction detected a secret but the
|
|
65
|
+
* redacted content could not be written back in place.
|
|
66
|
+
*/
|
|
67
|
+
const UNWRITABLE_FILE_NOTE =
|
|
68
|
+
"[content omitted from export: redaction detected a secret but could not rewrite this staged file]\n";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Replace a staged file's content with a short omission note. Removal +
|
|
72
|
+
* recreation is a directory-level operation, so it succeeds regardless of
|
|
73
|
+
* the file's own mode bits (the staging dir is daemon-created and writable).
|
|
74
|
+
* Throws if the replacement fails — callers rely on that to fail closed
|
|
75
|
+
* rather than letting the tar step ship the original content.
|
|
76
|
+
*/
|
|
77
|
+
function replaceFileWithNote(filePath: string, note: string): void {
|
|
78
|
+
rmSync(filePath, { force: true });
|
|
79
|
+
writeFileSync(filePath, note, { encoding: "utf-8", mode: 0o600 });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Bytes read from the head of each staged file to classify it as text
|
|
84
|
+
* (sweep it) or binary (leave it untouched).
|
|
85
|
+
*/
|
|
86
|
+
const TEXT_SNIFF_BYTES = 8 * 1024;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Heuristic text check: a file is treated as text when its first
|
|
90
|
+
* `TEXT_SNIFF_BYTES` contain no NUL byte. Binary formats (images, archives,
|
|
91
|
+
* SQLite, …) virtually always carry a NUL early; redacting them as text
|
|
92
|
+
* would corrupt them for no benefit.
|
|
93
|
+
*/
|
|
94
|
+
function sniffsAsText(filePath: string): boolean {
|
|
95
|
+
const fd = openSync(filePath, "r");
|
|
96
|
+
try {
|
|
97
|
+
const buffer = Buffer.alloc(TEXT_SNIFF_BYTES);
|
|
98
|
+
const bytesRead = readSync(fd, buffer, 0, buffer.length, 0);
|
|
99
|
+
return !buffer.subarray(0, bytesRead).includes(0);
|
|
100
|
+
} finally {
|
|
101
|
+
closeSync(fd);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Redact a serialized-JSON document while preserving its validity: parse,
|
|
107
|
+
* redact every string leaf, and re-stringify with `indent` (unchanged text
|
|
108
|
+
* is returned byte-identical). A leading UTF-8 BOM — common in user
|
|
109
|
+
* attachments — would make `JSON.parse` throw, so it is stripped for parsing
|
|
110
|
+
* and re-prepended on changed output to keep the content faithful. Falls
|
|
111
|
+
* back to plain-text redaction when the text isn't valid JSON — it is
|
|
112
|
+
* already malformed, so there is no validity to preserve.
|
|
113
|
+
*/
|
|
114
|
+
function redactSerializedJson(text: string, indent?: number): string {
|
|
115
|
+
const bom = text.startsWith("\uFEFF") ? "\uFEFF" : "";
|
|
116
|
+
let parsed: unknown;
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(bom ? text.slice(1) : text);
|
|
119
|
+
} catch {
|
|
120
|
+
return redactSecrets(text);
|
|
121
|
+
}
|
|
122
|
+
const { value, changed } = redactJsonStringLeaves(parsed);
|
|
123
|
+
return changed ? bom + JSON.stringify(value, null, indent) : text;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Redact a `.jsonl` file's content line by line, preserving each line's
|
|
128
|
+
* JSON validity (compact re-stringify, no pretty-printing). Unchanged lines
|
|
129
|
+
* map to themselves, so split/join leaves clean content byte-identical.
|
|
130
|
+
*/
|
|
131
|
+
function redactJsonlContent(content: string): string {
|
|
132
|
+
return content
|
|
133
|
+
.split("\n")
|
|
134
|
+
.map((line) => (line.trim() === "" ? line : redactSerializedJson(line)))
|
|
135
|
+
.join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function redactContent(content: string, ext: string): string {
|
|
139
|
+
if (ext === ".json") return redactSerializedJson(content, 2);
|
|
140
|
+
if (ext === ".jsonl") return redactJsonlContent(content);
|
|
141
|
+
return redactSecrets(content);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Run a secret-redaction sweep over every staged text file under
|
|
146
|
+
* `stagingDir`, rewriting in place any file where `redactSecrets()` found a
|
|
147
|
+
* match. Unchanged files are left byte-identical (no gratuitous rewrites).
|
|
148
|
+
*
|
|
149
|
+
* Per-file failures *before* anything sweep-worthy is detected (unreadable
|
|
150
|
+
* or unstattable files) are logged and skipped — they leak nothing the
|
|
151
|
+
* sweep would have changed, and a single bad file must never fail the
|
|
152
|
+
* export (degraded mode beats no archive at all). Once a file is known to
|
|
153
|
+
* need rewriting (a secret was detected, or it is oversized), failures fail
|
|
154
|
+
* closed instead: the file's content is replaced with an omission note, and
|
|
155
|
+
* if even that fails the error propagates and the export fails rather than
|
|
156
|
+
* shipping the original content.
|
|
157
|
+
*
|
|
158
|
+
* `filesOmitted` counts files whose content was replaced with an omission
|
|
159
|
+
* note: oversized files (never read, so not counted as scanned) and files
|
|
160
|
+
* whose redacted content could not be written back (scanned, but not
|
|
161
|
+
* counted as redacted — the redaction never landed).
|
|
162
|
+
*/
|
|
163
|
+
export function redactStagedExportFiles(stagingDir: string): {
|
|
164
|
+
filesScanned: number;
|
|
165
|
+
filesRedacted: number;
|
|
166
|
+
filesOmitted: number;
|
|
167
|
+
} {
|
|
168
|
+
let filesScanned = 0;
|
|
169
|
+
let filesRedacted = 0;
|
|
170
|
+
let filesOmitted = 0;
|
|
171
|
+
|
|
172
|
+
const stack: string[] = [stagingDir];
|
|
173
|
+
while (stack.length > 0) {
|
|
174
|
+
const dir = stack.pop()!;
|
|
175
|
+
let entries: Dirent[];
|
|
176
|
+
try {
|
|
177
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
178
|
+
} catch (err) {
|
|
179
|
+
log.warn(
|
|
180
|
+
{ err, dir },
|
|
181
|
+
"Failed to read staged export directory during secret sweep; skipping",
|
|
182
|
+
);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
for (const entry of entries) {
|
|
186
|
+
const filePath = join(dir, entry.name);
|
|
187
|
+
if (entry.isDirectory()) {
|
|
188
|
+
stack.push(filePath);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
// Dirent type checks use lstat semantics, so symlinks are neither
|
|
192
|
+
// files nor directories here — they are skipped, never followed.
|
|
193
|
+
if (!entry.isFile()) continue;
|
|
194
|
+
const ext = extname(entry.name).toLowerCase();
|
|
195
|
+
|
|
196
|
+
// Phase 1 — classify and scan. Failures here mean the file's content
|
|
197
|
+
// was never read into the sweep, so skipping it leaks nothing.
|
|
198
|
+
let size = 0;
|
|
199
|
+
let content = "";
|
|
200
|
+
let redacted = "";
|
|
201
|
+
try {
|
|
202
|
+
if (!sniffsAsText(filePath)) continue;
|
|
203
|
+
size = statSync(filePath).size;
|
|
204
|
+
if (size <= MAX_SWEEP_FILE_BYTES) {
|
|
205
|
+
content = readFileSync(filePath, "utf-8");
|
|
206
|
+
filesScanned++;
|
|
207
|
+
redacted = redactContent(content, ext);
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
log.warn(
|
|
211
|
+
{ err, file: filePath },
|
|
212
|
+
"Failed to sweep staged export file for secrets; continuing",
|
|
213
|
+
);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Phase 2 — rewrite. From here the file is known to need new content,
|
|
218
|
+
// so failures must not fall through to the tar step with the original
|
|
219
|
+
// bytes. `replaceFileWithNote` throws on failure, failing the export.
|
|
220
|
+
if (size > MAX_SWEEP_FILE_BYTES) {
|
|
221
|
+
// Fail closed: an oversized sweep-eligible file must not ship
|
|
222
|
+
// unswept — it may carry legacy plaintext secrets the sweep exists
|
|
223
|
+
// to catch. Normally unreachable now that every staged section is
|
|
224
|
+
// bounded at the source; retained as the final belt so assumption
|
|
225
|
+
// drift (a future unbounded section) can't silently ship unswept
|
|
226
|
+
// secrets.
|
|
227
|
+
log.warn(
|
|
228
|
+
{ file: filePath, size },
|
|
229
|
+
"Staged export file exceeds secret sweep size cap; replacing content with omission note",
|
|
230
|
+
);
|
|
231
|
+
replaceFileWithNote(filePath, OVERSIZED_FILE_NOTE);
|
|
232
|
+
filesOmitted++;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (redacted === content) continue;
|
|
236
|
+
try {
|
|
237
|
+
// Staged copies inherit source mode bits (`cpSync` preserves them),
|
|
238
|
+
// so a read-only workspace attachment arrives read-only and the
|
|
239
|
+
// rewrite below would throw. Loosening the mode on the export copy
|
|
240
|
+
// is an accepted tradeoff: the staging dir is daemon-private, and a
|
|
241
|
+
// mode-bit change in the tar is far better than a leaked secret.
|
|
242
|
+
// Clean files are never chmod'd, keeping them byte- and
|
|
243
|
+
// mode-identical.
|
|
244
|
+
chmodSync(filePath, 0o600);
|
|
245
|
+
writeFileSync(filePath, redacted, "utf-8");
|
|
246
|
+
filesRedacted++;
|
|
247
|
+
} catch (err) {
|
|
248
|
+
log.warn(
|
|
249
|
+
{ err, file: filePath },
|
|
250
|
+
"Failed to write redacted content back to staged export file; replacing content with omission note",
|
|
251
|
+
);
|
|
252
|
+
replaceFileWithNote(filePath, UNWRITABLE_FILE_NOTE);
|
|
253
|
+
filesOmitted++;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { filesScanned, filesRedacted, filesOmitted };
|
|
259
|
+
}
|