agentleFS
h0rn3th0rn3t/golang-skills/skills/go-defensive/SKILL.md

go-defensive

Skill7 starsChanged 10 days agoApache-2.0View on GitHub

Use when hardening Go API boundaries or checking slice/map copies, aliasing, typed nils, cleanup/defer, narrowing overflow, float equality, nil channels, time.Time and Duration values, globals, interface compliance, or filesystem/crypto safety. Error strategy belongs to go-error-handling.

1 other project uses a version of this, 1 of them edited.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 0 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use time.Time and time.Duration for instants and spans in process, never raw integers.

    line 99
  2. Very specific:

    Do not use math/rand or math/rand/v2 to generate keys.

    line 115
  3. Very specific:

    When a path comes from a caller, request, config file, or archive entry, open it through os.Root (Go 1.24+) instead of filepath.Join + …

    line 126

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Go Defensive Programming Patterns

Compatibility: Baseline Go 1.27 (see COMPATIBILITY.md). url.URL.Clone and url.Values.Clone require Go 1.27+; Root.ReadFile and Root.MkdirAll Go 1.25+; crypto/rand.Text and os.Root Go 1.24+; slices.Clone/maps.Clone Go 1.21+.

Resource Routing

  • references/BOUNDARY-COPYING.md - Read when copying slices/maps across API boundaries.
  • references/GLOBAL-STATE.md - Read when introducing or removing package globals.
  • references/MUST-FUNCTIONS.md - Read when deciding whether a panic-on-error helper is acceptable.
  • references/PANIC-RECOVER.md - Read when evaluating panic, recover, or crash containment.
  • references/ARCHIVES.md - Read when extracting a tar or zip archive.
  • references/TIME-ON-THE-WIRE.md - Read when a time.Duration or time.Time field crosses JSON or another wire format.
  • ../go-http/references/JSON-V2.md - Read when JSON v2 changes nil collections, tags, accepted input, or compatibility at an API boundary (Go 1.27+).

Common Pitfalls

PitfallRule
Typed nil in an interfaceA *T(nil) stored in an interface — an error result, a field of interface type — is non-nil: err != nil is true and a call through it dereferences nil. Return a literal nil, and declare the result as error, not *MyErr (go-error-handling owns the API rule)
Bare x.(T) assertionComma-ok unless a mismatch is a programming error that should panic (go-interfaces); reflection code prefers reflect.TypeAssert[T] (Go 1.25+)
append aliasingBoth slices share the backing array while capacity allows. s[:len(s):len(s)] only caps capacity so the next append reallocates — existing elements still alias; slices.Clone(s) is the copy (see go-data-structures)
int64 to int32 without a bounds checkValues wrap silently; compare against math.MaxInt32/math.MinInt32 first
Float ==math.Abs(a-b) <= max(absTol, relTol*max(math.Abs(a), math.Abs(b))) — the relative term alone is never true against 0 (0.1+0.2-0.3 vs 0), a fixed epsilon alone is wrong at large magnitudes; absTol is the size below which the domain treats a value as zero; money in integer minor units or big.Rat, never float64 or big.Float (binary: ten 0.1s do not sum to 1)
time.Time ==, or as a map key== also compares the Location and the monotonic reading, so it fails after a JSON or database round trip or t.In(loc): compare with t.Equal(u); a key stores t.UTC().Round(0) or t.UnixNano()
defer in a loopCalls fire at function exit, not per iteration — extract the body (behavior note in go-code-refactor)
Nil channelSend and receive block forever, so an unmade channel field is a hang, not an error — a deliberate nil in a select is the idiom for disabling that case (channel ownership: go-concurrency)
Integer division by zeroPanics; guard the divisor. Float division yields Inf/NaN instead, and every comparison with NaN is false, so x/0.0 >= limit silently answers no: guard a float divisor too
Elapsed time from a converted starttime.Now().UTC(), .In, .Local, and .Round(0) drop the monotonic reading, so end.Sub(start) jumps with a wall-clock step: take start := time.Now() and time.Since(start)

Copy Slices and Maps at Boundaries

Slices and maps contain pointers to underlying data. Copy at API boundaries to prevent unintended modifications. Prefer stdlib clones when their nil and capacity behavior fits the contract (conditions in the reference below):

d.trips = slices.Clone(trips)        // receiving; a nil argument stays nil
d.tags = append([]string{}, tags...) // receiving, when d.tags must encode as []
return maps.Clone(s.counters)        // returning; a nil map stays nil
return u.Clone()                     // *url.URL, Go 1.27+
return params.Clone()                // url.Values, Go 1.27+ (deep-copies values)

slices.Clone and maps.Clone are shallow: []*T and map[K][]V copies still alias their elements. A type-specific Clone has only the copy depth in its documented contract; do not substitute shallow and deep copies as a style change. slices.Clone and maps.Clone also preserve nil, which encoding/json v1 writes as null; for a required [] or {} output, keep the non-nil append or make copy above. See BOUNDARY-COPYING.md.

Defer to Clean Up

Close on a written file can report a write that failed late, so its error joins the function's result through a named err:

func save(name string, r io.Reader) (err error) {
    f, err := os.Create(name)
    if err != nil {
        return err
    }
    defer func() { err = errors.Join(err, f.Close()) }()
    _, err = io.Copy(f, r)
    return err
}

A file that is only read takes os.ReadFile or root.ReadFile when its whole content fits in memory, and the same deferred errors.Join when it is streamed.

Struct Field Tags

Advisory: Add explicit field tags to structs that are marshaled or unmarshaled.

type User struct {
    Name  string `json:"name"  yaml:"name"`
    Email string `json:"email" yaml:"email"`
}

JSON v2 tag options (embed, not the experimental inline) are in JSON-V2.md.

Time

Use time.Time and time.Duration for instants and spans in process, never raw integers. On the wire, encoding/json/v2 (Go 1.27+) has no default representation for time.Duration and fails to marshal it, so a wire field is an integer with the unit in its name or a string parsed with time.ParseDuration (TIME-ON-THE-WIRE.md).

Avoid Mutable Globals

Inject dependencies instead of mutating package-level variables. This makes code testable without global save/restore. The smallest injection is an argument: the caller passes time.Now() and a test passes a fixed instant; code that sleeps is tested inside synctest.Test, whose clock is fake (GLOBAL-STATE.md).

Crypto Rand

Do not use math/rand or math/rand/v2 to generate keys.

token := rand.Text() // crypto/rand: at least 128 bits, base32

For raw key material, rand.Read(buf) has no error to check: it never returns one, and it crashes the program if the OS source fails.

Confine Filesystem Access

When a path comes from a caller, request, config file, or archive entry, open it through os.Root (Go 1.24+) instead of filepath.Join + os.Open. Root resolves every component inside the directory, so ../../etc/passwd and a symlink pointing out of the tree both fail instead of escaping. A server opens the root once at startup and keeps it on the struct that serves requests:

type server struct {
    uploads *os.Root // opened once, held for the server's lifetime
}

func newServer(dir string) (*server, error) {
    uploads, err := os.OpenRoot(dir)
    if err != nil {
        return nil, err
    }
    return &server{uploads: uploads}, nil
}

func (s *server) upload(name string) ([]byte, error) {
    return s.uploads.ReadFile(name) // cannot escape dir
}

filepath.Clean is not a substitute: it keeps a leading .., so filepath.Join(dir, filepath.Clean("../../etc/passwd")) is /etc/passwd. A name that is stored or compared but never opened — an object-store key, a manifest entry — is checked with filepath.IsLocal(name), which is lexical: a name that is opened still goes through the root.

A subprocess is outside the root, because it resolves a name itself. Open the file through the root and hand the child the open file (cmd.Stdin, cmd.ExtraFiles), kept open until the child exits; a program that needs a path needs a trusted staging directory or OS isolation, and cmd.Dir confines nothing.

Deletes Name What They Remove

os.RemoveAll(filepath.Join(dir, name)) deletes dir itself when the name cleans to .: filepath.Join(dir, "") and filepath.Join(dir, "a/..") are both dir, and filepath.IsLocal("a/..") is true. root.RemoveAll refuses . and .., but a name that reaches the root through an existing entry does not: on go1.27.1 root.RemoveAll("ws/.."), with ws present, deletes everything under the root before it returns invalid argument. A {id} wildcard receives ws/.. from ws%2F.., . from %2E, and .. from %2E%2E. Rebuild the name from a parsed value, so it names exactly one entry:

id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
    http.Error(w, "invalid workspace id", http.StatusBadRequest)
    return
}
err = s.workspaces.RemoveAll(id.String()) // a canonical UUID is one entry

Archive Entries

An archive entry's name, type, and size are all input: parents through root.MkdirAll, no link an entry names, and a cap on the total size (ARCHIVES.md has the archive/tar loop and the archive/zip checks).

Panic and Recover

An error caused by input or the environment — a malformed request, a missing file, a refused connection — never crosses a package boundary as a panic: return it. A programming error may cross as one: an argument the API's documentation forbids, a MustX call at initialization, the unreachable default of a switch over a closed enum. A panic a package raises for its own control flow is recovered at its boundary and returned as an error. net/http recovers only the goroutine running the handler, so a goroutine you start recovers in its own deferred function or its panic ends the process. The patterns and their traps are in PANIC-RECOVER.md.

Must Functions

Must functions panic on error — use them only at program initialization on a value fixed when the program is built (regexp.MustCompile on a pattern literal, template.Must on an embedded template), so a failure is a bug; never on a file read at run time, the environment, or request-time input. MUST-FUNCTIONS.md has the shape and the exceptions.


Related Skills

  • go-error-handling: wrapping, sentinels, and typed errors at boundaries; this skill owns panic versus return.
  • go-interfaces: compile-time interface assertions.
  • go-style-core: the iota block and whether zero is a valid member.
  • go-security: untrusted values: injection, SSRF, secrets, TLS; this skill owns the os.Root and crypto/rand forms.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.