workspacescan

package
v0.5.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package workspacescan deterministically detects a local directory's (or a cloned repo's) development conventions — languages, package managers, implied egress registries, dev-container/Dockerfile presence, tools, and git remotes — so Wardyn can onboard a workspace with a profile grounded in what's ACTUALLY in the tree, not an LLM guess.

It clones internal/gitremote's conventions: read-only, bounded filepath.WalkDir (depth<=6, see scan.go's maxDepth), a manifest-count cap and a 1 MiB per-file read cap, NO symlink following, a control-char scrub on any string that crosses out of the scan, sorted+deduped output, NO subprocess/exec, and fail-safe-to-empty — Scan and DeriveProfile never return an error; a scan that hits a bound or an unrecognized build system just yields a lower-confidence profile, never a crash or a grant on uncertainty.

Two data shapes (A2, isolation-critical split):

  • ScanFacts is raw bounded evidence a scan emits. When it comes from a sandboxed repo scan (governed run, a later wave) it crosses the sandbox boundary and MUST be treated as untrusted.
  • WorkspaceProfile is the validated authority the control plane derives (DeriveProfile) and persists. Egress hosts in a WorkspaceProfile ONLY ever come from the fixed markers.go table, keyed on filenames — NEVER from file contents — so a hostile manifest body can't inject a host.

Index

Constants

View Source
const (
	ConfidenceHigh   = "high"
	ConfidenceMedium = "medium"
	ConfidenceLow    = "low"
)

Confidence buckets how much a WorkspaceProfile can be trusted without a human/AI review pass.

View Source
const (
	SourceDeterministic = "deterministic"
	SourceAIAssisted    = "ai_assisted"
)

Source records how a WorkspaceProfile was derived. Wave 1 only ever produces SourceDeterministic; SourceAIAssisted is reserved for the (later, not-this-wave) AI fallback pass.

View Source
const EnvAsCodeDockerfilePath = genDockerfilePath

EnvAsCodeDockerfilePath exports genDockerfilePath for callers outside this package that need to single the generated Dockerfile out from EmitEnvAsCode's output — namely internal/api/workspace_envcode.go's writeEnvAsCode, which refuses to overwrite a PRE-EXISTING file at this path. Unlike every other emitted key (devcontainer.json/AGENTS.md/the artifact redirect stubs, all Wardyn's own narrow, regenerate-on-demand output), a Dockerfile at .devcontainer/Dockerfile is exactly where an operator would already have hand-authored their own, for reasons that have nothing to do with Wardyn — so it is the one emitted file "write into the directory" must not silently clobber.

Variables

This section is empty.

Functions

func EmitEnvAsCode

func EmitEnvAsCode(p WorkspaceProfile, artifactBases map[string]string, baseRef string) (map[string]string, error)

EmitEnvAsCode produces committable environment-as-code from a scanned profile: a devcontainer.json (base + language features + the standard agent-tool install + artifact-registry redirects) and an AGENTS.md documenting the DETECTED toolchain and setup commands (profile.SetupCommands, a scan-time heuristic — never verified) as prose, for a human/agent to run deliberately. Returned as path -> content.

artifactBases maps an artifact ecosystem (npm|pip|cargo|maven|go|nuget) to the operator's corporate registry base URL (from the persisted site-config, URL-ONLY — never a token). The caller (api.artifactBaseURLs) derives this from the Ecosystem-tier subset of types.SiteConfig.EgressRedirects, already skipping every NETWORK-ONLY row (Ecosystem "") — this function only ever sees an ecosystem that actually wants a config file. When non-empty, the matching per-tool config files (and go's containerEnv) are merged in so a committed workspace pulls from the corporate mirror; pass nil when no redirect is configured.

baseRef is the workspace's OWN resolved base-image ref (a registry/custom/ byo pick's Image), or "" for "recommended"/nil — see baseOrBuild.

func GenerateDevcontainer

func GenerateDevcontainer(p WorkspaceProfile) (files map[string]string, err error)

GenerateDevcontainer produces a minimal, deterministic .devcontainer/devcontainer.json for the profile: the universal base image plus one devcontainer feature per detected, feature-supported language, plus the standard agent-tool install (genStandardTools), baked unconditionally. A .devcontainer/Dockerfile is always emitted alongside it and devcontainer.json points `build.dockerfile` at that instead of naming the base directly (see genAgentToolInstalls). The returned map is path -> file content; it is safe to feed straight to the envbuilder local-context build (BuildFromDevcontainerFiles).

Pure: no I/O, no clock, no randomness. p.Languages is already sorted+deduped by DeriveProfile, so iterating it and letting encoding/json sort the features map yields identical bytes for identical profiles.

func ShouldAdvise

func ShouldAdvise(base WorkspaceProfile, facts ScanFacts) bool

ShouldAdvise reports whether the AI fallback is worth invoking: only when the deterministic pass is LOW confidence or left UnrecognizedSamples. A high-confidence profile with nothing unresolved needs no AI and is returned unchanged by the caller (it never even calls AdviseProfile).

func ToolchainNeeds added in v0.5.0

func ToolchainNeeds(profiles ...WorkspaceProfile) (goNeeded, jvmNeeded bool)

ToolchainNeeds reports which toolchain-fidelity accommodations a run over these profiles actually needs. The dispatch env (runs_dispatch.go's buildBaseSandboxEnv) is requirements-driven, never platform-wide — the owner's rule: what's in a container follows from the workspace's ACTUAL requirements. Go's tempdir/cache redirect applies only when a scan detected Go; the Maven/Gradle JVM proxy sysprops only when the matching package manager was detected (the same signals gen.go's devcontainer emission and deriveSetupCommands already key on).

Types

type AIOptions

type AIOptions struct {
	Bin     string        // CLI binary path; empty → "claude" via PATH
	Timeout time.Duration // per-invocation bound; <=0 → aiDefaultTimeout
}

AIOptions configures the advisory CLI invocation. Zero values are safe: Bin defaults to "claude" (resolved via PATH), Timeout defaults to aiDefaultTimeout.

type GitRemotes

type GitRemotes struct {
	GitHub     []string `json:"github,omitempty"`
	OtherHosts []string `json:"other_hosts,omitempty"`
}

GitRemotes mirrors internal/gitremote.DetectGitHubRepos's (github, otherHosts) return shape as a named, JSON-friendly struct: the sorted "owner/repo" GitHub remotes found, and the sorted set of non-GitHub remote HOSTS (for an operator warning / git_pat grant grounding).

type LeakFinding

type LeakFinding struct {
	Path string `json:"path"`
	Kind string `json:"kind"`
	Line int    `json:"line,omitempty"`
	// Source names the attached source this finding came from (the source's
	// locator) once N sources are merged into one workspace profile; empty for
	// a single-source profile. Attribution is a SEPARATE field rather than a
	// prefix on Path because Path is path-CLASSIFIED downstream (the client's
	// testdata|__tests__|fixtures fixture check): folding the locator into it
	// makes every leak in a source whose own path contains such a segment
	// classify as a fixture. Path therefore stays scan-root-relative and
	// means exactly what it says.
	Source string `json:"source,omitempty"`
}

LeakFinding is a CONTENT-FREE report of a suspected committed secret VALUE. The leaked-value detector (detect.go) is the ONE lane that reads file values — to recognize a secret-shaped token — but it stores only WHERE and WHAT KIND, never the matched bytes: Kind is a detector id ("aws-access-key", "github-pat", ...), never the value. This mirrors internal/contentscan's content-free Finding discipline.

type ManifestHit

type ManifestHit struct {
	Path   string `json:"path"`   // slash-separated, relative to the scan root
	Marker string `json:"marker"` // canonical marker id, e.g. "package-lock.json"
}

ManifestHit is one recognized marker file found during a scan.

type ScanFacts

type ScanFacts struct {
	ManifestsFound      []ManifestHit        `json:"manifests_found,omitempty"`
	GitRemotes          GitRemotes           `json:"git_remotes,omitempty"`
	HasDevcontainer     bool                 `json:"has_devcontainer,omitempty"`
	HasDockerfile       bool                 `json:"has_dockerfile,omitempty"`
	UnrecognizedSamples []UnrecognizedSample `json:"unrecognized_samples,omitempty"`
	Truncated           bool                 `json:"truncated,omitempty"`

	// Content-lane evidence (detect.go): names/keys/hosts only, extracted via
	// anchored capture groups — no file value ever lands here. Like every other
	// fact these are UNTRUSTED until DeriveProfile re-validates and caps them.
	SecretRequirements []SecretNeed  `json:"secret_requirements,omitempty"`
	ServicesFound      []string      `json:"services_found,omitempty"`
	SuggestedEgress    []string      `json:"suggested_egress,omitempty"`
	SecretFilesPresent []string      `json:"secret_files_present,omitempty"`
	BuildMemoryMiB     int           `json:"build_memory_mib,omitempty"`
	LeakFindings       []LeakFinding `json:"leak_findings,omitempty"`
	// Raw setup-command SIGNALS (not commands): which conventional script/target
	// keys exist. DeriveProfile synthesizes fixed-template SetupCommands from
	// these + the detected package managers — file content never becomes a command.
	ScriptKeys  []string `json:"script_keys,omitempty"`  // package.json scripts: build|test|lint present
	MakeTargets []string `json:"make_targets,omitempty"` // Makefile targets: build|test|install|lint present
	// BuildInputHashes maps a build-input file's rel path to a hex sha256 of its
	// CONTENT (devcontainer.json / Dockerfile). A digest, not content — safe.
	BuildInputHashes map[string]string `json:"build_input_hashes,omitempty"`
}

ScanFacts is the raw bounded evidence a scan emits. It is untrusted input to DeriveProfile: a WorkspaceProfile is always re-derived from these facts control-plane-side, never taken on faith from whatever produced them.

func CollectFacts

func CollectFacts(root string) ScanFacts

CollectFacts walks root and collects the raw, bounded ScanFacts. This is the entry point the in-sandbox wardyn-scan binary calls: it emits the untrusted facts a governed repo scan ships back over the brokered scan-result route, which the control plane re-derives into a WorkspaceProfile (never trusting the facts on faith — see DeriveProfile).

type SecretNeed

type SecretNeed struct {
	Name     string `json:"name"`
	Kind     string `json:"kind,omitempty"`
	Optional bool   `json:"optional,omitempty"`
}

SecretNeed is one secret/config key a workspace's committed files REFERENCE BY NAME — never a value. Detectors (detect.go) capture only the identifier left of the '='/':' delimiter or inside a ${...} placeholder; the rest of the line is discarded before anything is stored. Optional means the file declared a safe default (Spring `${VAR:default}`), a commented template line, or a deploy-time key (SealedSecret) — surfaced for information, never a launch blocker. Kind is a coarse env-name-family classification ("postgres", "oidc", "deploy", ... — see classifySecretKind); "generic" when unknown.

type SetupCommand

type SetupCommand struct {
	Stage   string `json:"stage"`   // install | build | test | lint
	Command string `json:"command"` // fixed-template command, never file content
	Source  string `json:"source"`  // what implied it, e.g. "convention:go", "package.json:build"
}

SetupCommand is one conventional environment-setup step a workspace implies: install dependencies, build, test, or lint. SECURITY: the Command string is NEVER copied from a file's content (a hostile package.json `scripts.build` could be `rm -rf`); it is synthesized from a FIXED template keyed on the detected package manager + which conventional script/target KEYS exist — exactly the filename-keyed discipline egress hosts use. Advisory only: a command is surfaced for operator review and only ever executed inside a confinement sandbox after explicit approval (mirrors SuggestedEgress).

type UnrecognizedSample

type UnrecognizedSample struct {
	Path    string `json:"path"`
	Content string `json:"content"`
}

UnrecognizedSample is a bounded, scrubbed snippet of a file that looked like a build/dependency descriptor but isn't in the fixed marker table — evidence for the (later) AI fallback. Content is truncated and has control characters stripped; it is never large enough, nor selected in a way, to leak a secret value.

type WorkspaceProfile

type WorkspaceProfile struct {
	Languages       []string `json:"languages,omitempty"`
	PackageManagers []string `json:"package_managers,omitempty"`
	// (ToolchainNeeds below reads these two — the dispatch env derives from
	// what the scan actually detected, never from a platform-wide guess.)
	EgressDomains   []string   `json:"egress_domains,omitempty"`
	Tools           []string   `json:"tools,omitempty"`
	GitRemotes      GitRemotes `json:"git_remotes,omitempty"`
	HasDevcontainer bool       `json:"has_devcontainer,omitempty"`
	HasDockerfile   bool       `json:"has_dockerfile,omitempty"`

	// Advisory "needs" fields (content lane, validated by DeriveProfile).
	// RequiredSecrets/ServicesNeeded/SecretFilesPresent inform the operator
	// (needs panel, setup checklist) and never gate a launch or create a
	// grant. SuggestedEgress is content-derived and is NEVER auto-unioned
	// into a run's allowlist (that privilege is EgressDomains-only, which
	// stays filename-keyed) — an operator promotes hosts into the
	// workspace's operator-owned ApprovedEgress list instead.
	// these advisory fields ride in ProfileHash, so a workspace's
	// first rescan after this change forces one no-op image rebuild. Ceiling:
	// harmless one-time churn; upgrade path — hash only the image-affecting
	// subset (Languages/HasDevcontainer/HasDockerfile) if it ever bites.
	RequiredSecrets    []SecretNeed `json:"required_secrets,omitempty"`
	ServicesNeeded     []string     `json:"services_needed,omitempty"`
	SuggestedEgress    []string     `json:"suggested_egress,omitempty"`
	SecretFilesPresent []string     `json:"secret_files_present,omitempty"`
	// BuildMemoryMiB is the largest build-heap ceiling detected (JVM -Xmx /
	// Node --max-old-space-size). Advisory: surfaced so an operator can size
	// the sandbox; never auto-applied to a run's ResourceLimits.
	BuildMemoryMiB int `json:"build_memory_mib,omitempty"`
	// LeakFindings are content-free reports of suspected committed secret
	// values (path + detector kind + line, never the value). Advisory warning.
	LeakFindings []LeakFinding `json:"leak_findings,omitempty"`
	// SetupCommands are the conventional install/build/test/lint steps this
	// workspace implies, synthesized from fixed templates (never file content).
	// Advisory: operator-approved before they ever run, and only in a sandbox.
	SetupCommands []SetupCommand `json:"setup_commands,omitempty"`
	// ContextHash is a digest of the BUILD-INPUT files' CONTENT (a repo's own
	// devcontainer.json / Dockerfile). It rides ProfileHash so the built-image
	// cache (BuiltProfileHash) busts when a build input changes even if the
	// detected profile is otherwise identical — the gap a profile-only hash has
	// for the repo-owns-its-devcontainer build path. Empty when no build-input
	// files are present (generated-devcontainer path is already profile-derived).
	ContextHash string `json:"context_hash,omitempty"`
	// Confidence is one of ConfidenceHigh/Medium/Low.
	Confidence  string `json:"confidence"`
	NeedsReview bool   `json:"needs_review,omitempty"`
	// Source is one of SourceDeterministic/SourceAIAssisted.
	Source string `json:"source"`
}

WorkspaceProfile is the validated, control-plane-owned authority derived from a scan. Every slice field is sorted + deduped. It is safe to persist and to hand to run-creation for egress/grant/image decisions (A6, a later wave).

func AdviseProfile

func AdviseProfile(ctx context.Context, facts ScanFacts, base WorkspaceProfile, opts AIOptions) WorkspaceProfile

AdviseProfile runs the advisory AI fallback and merges its (advisory-only) result into a COPY of base. It FAILS OPEN: on any error the base profile is returned unchanged. It never overrides or deletes a deterministic fact — it only gap-fills EMPTY fields and can only RAISE NeedsReview.

func DeriveProfile

func DeriveProfile(facts ScanFacts) WorkspaceProfile

DeriveProfile is the control-plane re-derivation of a WorkspaceProfile from previously-emitted ScanFacts — the SAME logic Scan uses internally, so a local Scan(root) and a facts round trip (Scan → marshal → unmarshal → DeriveProfile) always agree. Untrusted-input safe: an unknown Marker id (e.g. from a future scanner version) is silently ignored, never trusted.

func MergeProfiles added in v0.5.0

func MergeProfiles(profiles []WorkspaceProfile, identities []string, primaryIdentity string) WorkspaceProfile

MergeProfiles combines N local_dir/repo sources' individually-scanned profiles into ONE profile for the workspace: union the set-like fields (languages, package managers, egress, tools, required secrets, services, suggested egress), concatenate leak findings and setup commands (never drop a suspected secret or an install step, deduped so N sources can't repeat one, re-capped so N sources can't exceed one source's own bound), take the largest build-memory hint, fold ContextHash into a digest-of-digests, and take the LOWEST confidence (one ambiguous source makes the whole workspace's profile suspect). HasDevcontainer/HasDockerfile come from the PRIMARY source ONLY — unioning them would let a devcontainer that lives in a non-primary source get built as if it were the primary repo's own. primaryIdentity names that source (matched against identities, NOT profiles[0] — the caller's attachment order and this function's scan order can diverge whenever the first attachment is ephemeral or not yet scanned; see hydrateWorkspace in internal/store/store_sources.go, which derives it from the exact same ws.Sources[0] the consumer reads as "primary"). A primaryIdentity matching no profile (primary is ephemeral or unscanned) yields false/false, never another source's values. identities[i] names profiles[i]'s source (its locator, unique per source): SecretFilesPresent entries are prefixed "identity/path" so a merged finding still says which source it came from, while a LeakFinding carries the same attribution in LeakFinding.Source and keeps its Path scan-root-relative — Path is path-CLASSIFIED by the client (fixture vs hot), which a locator prefix would corrupt. Empty input returns the zero profile.

func Scan

func Scan(root string) WorkspaceProfile

Scan walks root (bounded, read-only, no symlink following) and derives a WorkspaceProfile. It never returns an error: a scan that hits a bound or an unrecognized build system yields a lower-confidence profile, never a crash.

func (WorkspaceProfile) CacheKey added in v0.5.0

func (p WorkspaceProfile) CacheKey() string

CacheKey returns the SHA-256 hex digest that keys a workspace's BUILT image cache (Workspace.BuiltProfileHash): a salted digest of ProfileHash, not ProfileHash itself, so bumping cacheKeySalt can force a rebuild without colliding with it.

func (WorkspaceProfile) ProfileHash

func (p WorkspaceProfile) ProfileHash() string

ProfileHash returns the SHA-256 hex digest of the profile's canonical (sorted-object-keys) JSON form. It's used to cache-key generated/built images (Workspace.BuiltProfileHash, a later wave): the same detected profile always hashes the same, regardless of Go struct field order.

encoding/json marshals a map[string]any with its keys sorted, so a marshal → unmarshal-into-map → marshal round trip is a standard-library-only way to get canonical JSON without hand-rolling a key sort.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL