workspacescan

package
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Jul 20, 2026 License: Apache-2.0 Imports: 19 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<=4), 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.

Variables

This section is empty.

Functions

func EmitArtifactConfig

func EmitArtifactConfig(bases map[string]string) (files map[string]string, env map[string]string)

EmitArtifactConfig turns operator-configured artifact-registry redirects (ecosystem -> corporate base URL) into the per-tool config each toolchain reads to pull from the corporate mirror instead of the public registry. Returns (files, env):

  • files: path -> content, keyed by each tool's real config location relative to HOME (npm .npmrc, pip .config/pip/pip.conf, cargo .cargo/config.toml, maven .m2/settings.xml, nuget .nuget/NuGet/NuGet.Config). A dispatch-time writer drops them under $HOME; a committable export drops the repo-cascading ones (.npmrc/.cargo) usefully at the repo root and the rest as documentation.
  • env: the go-toolchain variables (go redirects via GOPROXY/GOSUMDB env, not a file).

The output is URL-ONLY and carries NO secret — an injected registry token is applied proxy-side, never written into a committable/readable config file. Maven's settings.xml is intentionally MIRRORS-ONLY: the sandbox reaches the mirror THROUGH wardyn-proxy via MAVEN_OPTS (set platform-wide at dispatch), so no <proxies> block is emitted here — which also keeps a committed settings.xml free of the sandbox-only wardyn-proxy hostname (mirrors=which-URL is additive to proxies=how-to-reach, which lives in MAVEN_OPTS). GOPRIVATE is deliberately NOT set: GOPRIVATE="*" would route modules to direct VCS and defeat the corp GOPROXY, and GOSUMDB=off already disables the checksum DB the corp proxy may not serve. Pure + deterministic; unknown/empty ecosystems are skipped.

Injection safety: base URLs come from site-config, which validateSiteConfig already rejects if they contain control chars or shell/XML metacharacters (`$;&|<>"'\), so embedding base verbatim into TOML/XML/ini here is safe.

func EmitEnvAsCode

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

EmitEnvAsCode produces committable environment-as-code from a VERIFIED profile + its operator-approved setup commands: a devcontainer.json (base + language features + the install/build steps as postCreateCommand) and an AGENTS.md documenting the detected toolchain and setup commands. Returned as path -> content. The install/build stages become postCreateCommand (env setup); test/lint are documented in AGENTS.md but not auto-run on create.

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). 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.

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 official devcontainer feature per detected, feature-supported language. The returned map is path -> file content (a single entry); 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 HostOf

func HostOf(rawURL string) string

HostOf extracts the lowercase host of an http(s) URL, or "" if unparseable.

func MaskSecretShaped

func MaskSecretShaped(s string) string

MaskSecretShaped replaces any high-precision leaked-value match (AWS/GitHub/ Stripe/JWT/…) with a fixed placeholder, so a build log that happened to print a secret-shaped token never persists it. Reuses the leaked-value catalog.

func PublicRegistryHosts

func PublicRegistryHosts(ecosystem string) []string

PublicRegistryHosts returns the public-registry hosts a corporate redirect replaces for an artifact ecosystem (npm|pip|go|cargo|maven|nuget), or nil for an unknown key. The egress-substitution layer drops these and adds the corp host when the operator configures a redirect for that ecosystem.

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 ValidApprovedHost

func ValidApprovedHost(h string) bool

ValidApprovedHost reports whether h is a plain lowercase dotted host of the exact shape the content lane emits into SuggestedEgress — the only shape the approved-egress API accepts for operator promotion (no scheme, port, path, or wildcard; wildcards remain a policy-allowlist privilege).

func ValidSetupCommand

func ValidSetupCommand(c SetupCommand) bool

ValidSetupCommand reports whether c is an acceptable operator-approved setup command: a known stage and a single-line, bounded, control-char-free command (the operator vouches for what it DOES — it runs confined — but the string must be safe to store, audit, and stream). Source is advisory metadata and is not validated for content beyond length.

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"`
}

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 VerifyResult

type VerifyResult struct {
	Steps []VerifyStepResult `json:"steps,omitempty"`
	OK    bool               `json:"ok"`
	// Ran is false when there were no approved commands to run.
	Ran bool `json:"ran"`
	// Done distinguishes a final upload (finalize the workspace status) from an
	// intermediate PROGRESS upload (keep `verifying`, just show the steps so far).
	Done bool `json:"done,omitempty"`
	// Total is how many commands will run (so the UI can show "step N of Total").
	Total int `json:"total,omitempty"`
	// FailureHint explains the FIRST failing/timed-out step in operator terms,
	// when its exit code + log tail match a known environmental signature
	// (missing toolchain, Maven proxy, noexec /tmp) — see classifyFailureHint.
	// Empty when there is no failure or none of the signatures matched (the
	// existing "Suggest a fix from denied egress" flow stays the fallback).
	FailureHint string `json:"failure_hint,omitempty"`
}

VerifyResult is a verify run's outcome, uploaded by wardyn-verify (PROGRESS uploads with Done=false as each step starts/finishes, then a final Done=true upload) and re-validated by DeriveVerifyResult control-plane-side.

func DeriveVerifyResult

func DeriveVerifyResult(raw VerifyResult) VerifyResult

DeriveVerifyResult re-validates an untrusted VerifyResult upload: caps step count, coerces stage/command/exit to bounded/known values, masks secret-shaped tokens out of logs, bounds log length, and recomputes OK from the step exit codes (never trusting the uploader's OK flag). Mirrors DeriveProfile's facts-out-not-authority-out discipline.

type VerifyStepResult

type VerifyStepResult struct {
	Stage   string `json:"stage"`
	Command string `json:"command"`
	// Running marks the step currently executing in a PROGRESS upload (no exit
	// code yet). A final upload never carries Running steps.
	Running    bool   `json:"running,omitempty"`
	ExitCode   int    `json:"exit_code"`
	DurationMs int64  `json:"duration_ms,omitempty"`
	TimedOut   bool   `json:"timed_out,omitempty"`
	LogHead    string `json:"log_head,omitempty"`
	LogTail    string `json:"log_tail,omitempty"`
}

VerifyStepResult is one setup command's outcome. LogHead/LogTail are a bounded rolling head+tail of combined stdout/stderr (so both the setup context and the failure survive truncation).

type WorkspaceProfile

type WorkspaceProfile struct {
	Languages       []string   `json:"languages,omitempty"`
	PackageManagers []string   `json:"package_managers,omitempty"`
	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 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) 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