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
- func EmitEnvAsCode(p WorkspaceProfile, artifactBases map[string]string, baseRef string) (map[string]string, error)
- func GenerateDevcontainer(p WorkspaceProfile) (files map[string]string, err error)
- func ShouldAdvise(base WorkspaceProfile, facts ScanFacts) bool
- func ToolchainNeeds(profiles ...WorkspaceProfile) (goNeeded, jvmNeeded bool)
- type AIOptions
- type GitRemotes
- type LeakFinding
- type ManifestHit
- type ScanFacts
- type SecretNeed
- type SetupCommand
- type UnrecognizedSample
- type WorkspaceProfile
- func AdviseProfile(ctx context.Context, facts ScanFacts, base WorkspaceProfile, opts AIOptions) WorkspaceProfile
- func DeriveProfile(facts ScanFacts) WorkspaceProfile
- func MergeProfiles(profiles []WorkspaceProfile, identities []string, primaryIdentity string) WorkspaceProfile
- func Scan(root string) WorkspaceProfile
Constants ¶
const ( ConfidenceHigh = "high" ConfidenceMedium = "medium" ConfidenceLow = "low" )
Confidence buckets how much a WorkspaceProfile can be trusted without a human/AI review pass.
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.
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 ¶
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 ¶
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.