1 Commits

Author SHA1 Message Date
Privatae 4a5c310482 bastion 0.58.1
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 4m6s
ci / fuzz (push) Failing after 2m58s
2026-10-29 21:20:20 +00:00
26 changed files with 628 additions and 134 deletions
+41 -5
View File
@@ -2,11 +2,10 @@
All notable changes to the bastion.
The version in `deploy/VERSION` on `main` is the next release, not necessarily
the one machines run. A merged change waits there until it is published. The
published version is whatever <https://privatae.ai/bastion/version.json> names
at the moment, and it is the only version a bastion will update to. Between a
merge and a publish, `main` is ahead of the manifest.
The published version is whatever <https://privatae.ai/bastion/version.json>
names at the moment, and it is the only version a bastion will update to. This
public repository receives each release after it is published, so its
`deploy/VERSION` can trail that manifest.
Two things can keep a version listed here from reaching most machines:
@@ -23,6 +22,43 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once
that is settled. The newest entries may not be published yet; the manifest
says which version is. Older entries are not marked.
## 0.58.1
- **An excluded path that names more than one folder now matches.** A
shared folder's grant lists paths no Host may read, and each was compared
with one folder name at a time, so a path written with a slash never
matched. The Host's own default list includes `.config/gcloud`, so a
shared home folder served the Google Cloud command line's sign-in files
through list, stat and read_range. A path such as `.config/gcloud` now
matches that run of folders anywhere under the shared folder, with names
compared the way this machine's file system compares them, as one-folder
paths already were. Every check that reads the excluded paths uses it:
each folder op, the listing walk and both checks on a move plan.
- **The local search agent's own folder is never served.** The agent keeps
its extracted text, its sanitized text, its vectors, its catalog and its
sealed bundles in `.agent` inside the shared folder, and a Host allowed to
read the folder could list it and read the extracted text as it is. List,
stat, read_range and move plans now refuse `.agent` at any depth and in
any case, however the path is written or reached through a link, and say
why. The bastion's own plan reports still go to `.agent/plans`.
- **What flag mode replaces is said exactly.** The note a Host passes on
for a folder searched in flag mode said card, bank and ID numbers were
replaced. It now names what is replaced (e-mail addresses, phone numbers,
card and US social security numbers, IBANs, US routing numbers after a
word such as "routing", Bitcoin addresses that start with 1 or 3, IP and
MAC addresses, private keys and known token formats) and what is never
looked for (names, street addresses, dates of birth, passport, licence
and other ID numbers, other bank account numbers and other crypto
addresses).
- **The documentation says what listens and what each claim's test checks.**
The tunnel holds one UDP port, chosen by the system, which answers nothing
that is not from the paired Host's key; every other port is on loopback.
A coder run reaches nothing on this machine or your network that you did
not name with `--allow-lan`. Nine rows of `docs/claims.md` now say only
what their tests assert. The commands page lists every command, and the
install page the one-line pairing for a Cloud Host. `git-setup` no longer
says the forge can be reached only through the bastion.
## 0.58.0
- **A Host answers four common questions about a machine itself, instead of
+4 -4
View File
@@ -38,10 +38,10 @@ The backup tests also check this code against the age command itself when
one is on PATH or named by `AGE_BIN`; CI installs a pinned age release and
sets `PRIVATAE_REQUIRE_AGE`, so there they fail rather than skip.
The test suite covers all three platforms' parsing and judgement logic from
one `go test` on any OS. Platform-specific decisions live in files with no
build tag; only the enumerators are tagged. Keep it that way: a new judgement
goes in an untagged file with a table test. Check it with
Most parsing and judgement logic is in files with no build tag and runs under
one `go test` on any OS. Some platform parsers (on Linux and macOS) and the
Linux sandbox are in tagged files, and their tests run only on that OS. A new
judgement goes in an untagged file with a table test. Check it with
`GOOS=windows go vet ./...` and `GOOS=darwin go vet ./...` as well: `go build`
does not compile test files, so only vet sees a test that uses a Linux-only
name.
+35 -21
View File
@@ -22,9 +22,9 @@ gates. Your files stay where they are. Nothing on this machine drives the host:
there is no MCP endpoint here.
The published release is whatever `privatae.ai/bastion/version.json` names.
`deploy/VERSION` on `main` is the next release, which may not be published
yet. From 0.42.0 on, every release manifest is signed and every published
binary can be rebuilt from this source and compared
This repository receives each release after it is published, so
`deploy/VERSION` here can trail it. From 0.42.0 on, every release manifest is
signed and every published binary can be rebuilt from this source and compared
([Verify a release](#verify-a-release)). See [CHANGELOG.md](CHANGELOG.md).
```sh
@@ -71,10 +71,12 @@ A Privatae host is an AI that works for you. To be useful it needs to see your
files, use your machine's models, and sometimes act on the machine. The bastion
is the piece that makes that possible **without handing the host your machine**.
The bastion dials your host; your host never dials your machine. No port is
opened on any externally reachable interface (the control ports and the local
console bind loopback only; the optional memory-mirror forwarder also binds
the replica container's private gateway address). The only thing your host can reach is the
The bastion dials your host; your host never dials your machine. The only
ports it holds open on an outside interface are its tunnels' WireGuard UDP
ports, one per host it reaches through a tunnel, each at a port the system
picks, and WireGuard drops every packet there that does not come from that
host's key. Every other port it opens binds loopback (the optional memory-mirror
forwarder also binds the replica container's private gateway address). The only thing your host can reach is the
inside of the tunnel this binary brought up, and what it may do there arrives
as a signed grant that this binary verifies locally. Once you enrol a second
factor on this machine, anything that would move your files, download a model,
@@ -248,7 +250,8 @@ its own page, listed with one line each in [docs/README.md](docs/README.md):
Each point in full: [docs/security-model.md](docs/security-model.md); the link after each goes to its detail.
- **The bastion dials out.** Every lane is a reverse connection to the host;
no port is opened on an externally reachable interface. ([lanes](docs/lanes.md))
the only ports open on an outside interface are the tunnels' WireGuard
ports, which drop everything not from the host's key. ([lanes](docs/lanes.md))
- **Inside the tunnel, only gated listeners**: up to three inference forwards,
one port per service-slot server and one per coder run, each checked when a
connection is accepted. ([tunnel](docs/lanes.md#tunnel))
@@ -276,10 +279,11 @@ Each point in full: [docs/security-model.md](docs/security-model.md); the link a
and never change anything outside their declared file effects. ([ops](docs/ops.md))
- **A server in the service slot has no network.** The host's tunnel is its
only way in. ([service slot](docs/service-slot.md))
- **Egress for someone else reaches the public internet, never this
machine**: a coder run's proxy and an anchored machine's egress lane refuse
internal addresses and this machine's own (a coder run reaches a LAN host
you named). A local machine's egress lane refuses this machine and your
- **Egress for someone else reaches the public internet, and nothing on this
machine or your network that you did not name**: a coder run's proxy and an
anchored machine's egress lane refuse internal addresses and this machine's
own (a coder run reaches a host you named with `coders set --allow-lan`,
even when it is this machine or on your LAN). A local machine's egress lane refuses this machine and your
LAN too; your own machines are reached through their approved lanes. An address that reaches this machine only through a 1:1 NAT is
not covered. ([full text](docs/security-model.md))
- **A terminal is checked here and logged.** On a local machine it opens when
@@ -298,6 +302,10 @@ Each point in full: [docs/security-model.md](docs/security-model.md); the link a
that one backup. ([backups](docs/backups.md), [restoring a lost host](docs/backups.md#restoring-a-lost-host))
- **No delete op on shared stores.** The one write lane is a move plan you
approved, and it archives. ([DCN stores](docs/context.md#distributed-context-network-dcn-stores))
- **A shared folder serves only what its grant leaves in.** Every read and
move is held to the grant's excludes, `.config/gcloud` and other patterns of
several folders included, and the search agent's own folder (`.agent`) is
never read or moved. ([DCN stores](docs/context.md#distributed-context-network-dcn-stores))
- **Verified releases.** The self-updater checks the manifest's signature and
the binary's SHA-256 before swapping; the release key never reaches the
build machine. The installer checks the SHA-256 ([SECURITY.md](SECURITY.md)
@@ -324,9 +332,9 @@ Development happens in a private repository, and its history is not
published. When a release is published, each change merged since the last one
arrives on GitHub as a commit with the same files, titled as the change was
and dated when it was merged; the copy at git.privatae.ai receives the release
as one commit. In both, the release is tagged `v<version>` on a commit whose
tree is identical to the commit the signed release manifest names as
`built_from`. Every binary can be rebuilt from the tag and compared with the
as one commit. In both, the release is tagged `v<version>` (0.55.0 to 0.55.2
were not) on a commit whose tree is identical to the commit the signed release
manifest names as `built_from`. Every binary can be rebuilt from the tag and compared with the
published one: see [Verify a release](#verify-a-release).
Issues and pull requests are welcome on GitHub, at
@@ -359,7 +367,11 @@ CGO_ENABLED=0 go build -o privatae-bastion .
./privatae-bastion version
```
Every push runs the same checks in CI (`.gitea/workflows/ci.yml`).
Every push to `main` and every pull request into it runs the checks
[CONTRIBUTING.md](CONTRIBUTING.md) asks for in CI: `.github/workflows/ci.yml`
on GitHub, `.gitea/workflows/ci.yml` on git.privatae.ai. On GitHub it also
runs every test [docs/claims.md](docs/claims.md) cites, as root on a machine
of its own, and fails if any of them skips.
To build a specific release rather than the latest source, check out its tag
first (`git checkout v<version>`).
@@ -382,8 +394,9 @@ It checks the release manifest's signature against the keys pinned in
`client-go/release_sign.go`, checks out the release's source, rebuilds it, and
compares every binary's sha256 with the published one.
Each published release in this repository carries the tag `v<version>` (for
example `v0.54.1`). The manifest's `built_from` names the
From 0.53.2 on, each release in this repository carries the tag `v<version>`
(for example `v0.54.1`), apart from 0.55.0, 0.55.1 and 0.55.2, which have no
tag. The manifest's `built_from` names the
commit in the repository the release was built in, which is not a commit here,
so the script checks out the tag `v<version>` for the version the manifest
names, and prints which source it used. The tag is annotated and its message
@@ -396,8 +409,9 @@ bytes.
If the published version has no tag in your clone, the script says so and
stops without building. If it cannot fetch tags, it says that instead.
Releases from 0.53.2 on can be checked this way from a clone of this
repository; the tags of earlier releases are not in it.
Only a release tagged here can be checked this way from a clone of this
repository. A release arrives here after it is published, so the newest one
has no tag here until then.
Each release on GitHub carries the six binaries, the signed manifest
`version.json`, its signature `version.json.<first 16 hex of its sha256>.sig`
@@ -415,7 +429,7 @@ go run . verify bastion <dir>/version.json <dir>/version.json.<…>.sig $(go run
```
`deploy/release-assets.sh v<version> <dir>` does all of it from a clone,
rebuild included, for any release from 0.53.2 on.
rebuild included, for any release tagged here.
## Contributing
+8 -6
View File
@@ -102,13 +102,15 @@ one installed.
**Checking our work.** Release builds are reproducible.
`deploy/verify-release.sh` fetches the published manifest, checks its
signature, checks out the release's source, rebuilds it, and compares every
binary's digest. From 0.53.2 on, each published release in this repository
carries the annotated tag `v<version>`, whose message records the release's
`built_from`. That field names the commit in the repository the release was
built in, which is not a commit here, so the script uses the tag and prints
binary's digest. From 0.53.2 on, apart from 0.55.0 to 0.55.2, each release in
this repository carries the annotated tag `v<version>`, whose message records
the release's `built_from`. A release arrives here after it is published.
That field names the commit in the repository the release was built in,
which is not a commit here, so the script uses the tag and prints
which source it used. If the digests match, this source builds the binaries we
serve. Releases published before build reproducibility was added (0.40.0 and
earlier) cannot be checked this way.
serve. Only a release tagged here can be checked this way, and releases
published before build reproducibility was added (0.40.0 and earlier) cannot
be checked at all.
**The agent.** The bastion downloads the bastion agent, runs it, and gives it
the folder you granted. The agent is a separate program in its own repository
+2 -2
View File
@@ -624,8 +624,8 @@ func checkPairConflict(cfgs []*Config, handle string, replace bool) (*Config, er
}
// nextGitProxyAddr picks the first loopback port from 7978 up that no existing
// pairing claims: the per-host git proxy (git.privatae.ai is reachable only through
// the bastion → host → platform path). Allocated lazily for pairings that predate it.
// pairing claims: the per-host git proxy, through which local git reaches
// git.privatae.ai via that host (gitlane.go). Allocated lazily for pairings that predate it.
func nextGitProxyAddr(cfgs []*Config) string {
used := map[string]bool{}
for _, c := range cfgs {
+82 -9
View File
@@ -276,27 +276,82 @@ func dcnResolvePath(root, rpath string) (string, bool) {
return joined, true
}
// dcnExcluded reports whether rpath trips any exclude pattern. Patterns match the
// basename and every path segment (glob per segment: ".ssh", "*.key", ".env*"),
// compared the way this platform's file system compares names (dcnNameKey).
// dcnExcluded reports whether rpath trips any exclude pattern, or is inside a
// local search agent's folder (dcnInAgentDirOn), which no grant reaches. A
// pattern of one folder name matches any segment of the path (".ssh", "*.key",
// ".env*"); a pattern with a "/" (".config/gcloud") matches that run of
// consecutive segments wherever it sits in the path, each one a glob. Names
// are compared the way this platform's file system compares them (dcnNameKey).
// rpath is a real relative path; a path a host sent goes through dcnExcludedAsSent.
func dcnExcluded(excludes []string, rpath string) bool {
return dcnExcludedOn(runtime.GOOS, excludes, rpath)
}
func dcnExcludedOn(goos string, excludes []string, rpath string) bool {
if dcnInAgentDirOn(goos, rpath) {
return true
}
segs := strings.Split(filepath.ToSlash(rpath), "/")
for i, seg := range segs {
segs[i] = dcnNameKey(goos, seg)
}
for _, pat := range excludes {
pat = strings.TrimSpace(pat)
if pat == "" {
continue
}
pat = dcnNameKey(goos, pat)
for _, seg := range segs {
if ok, _ := filepath.Match(pat, dcnNameKey(goos, seg)); ok {
var run []string
for _, p := range strings.Split(pat, "/") {
if p != "" {
run = append(run, dcnNameKey(goos, p))
}
}
if dcnRunMatches(run, segs) {
return true
}
}
return false
}
// dcnRunMatches says whether the globs in run match len(run) consecutive
// segments of segs, starting anywhere. An empty run matches nothing.
func dcnRunMatches(run, segs []string) bool {
if len(run) == 0 {
return false
}
for i := 0; i+len(run) <= len(segs); i++ {
all := true
for j, p := range run {
if ok, _ := filepath.Match(p, segs[i+j]); !ok {
all = false
break
}
}
if all {
return true
}
}
return false
}
// dcnAgentDir is the folder the local search agent keeps everything it makes
// in, beside the files it reads: the plaintext of every file in scope, the
// sanitized text, vectors, the catalog and sealed bundles. The bastion puts
// it at <root>/.agent wherever it may write into the folder (freeagent_managed.go;
// the agent's own default too), and a folder inside a store can hold another
// store's. The agent's walker never reads one, at any depth and in any case;
// the file ops never serve one either.
const dcnAgentDir = ".agent"
// dcnInAgentDirOn reports whether any segment of rpath names an agent's
// folder, as the file system on goos names it, and in any case on every OS
// (as the agent's own walker judges it: a ".Agent" on a volume that ignores
// case is the agent's folder).
func dcnInAgentDirOn(goos, rpath string) bool {
for _, seg := range strings.Split(filepath.ToSlash(rpath), "/") {
if strings.EqualFold(dcnNameKey(goos, seg), dcnAgentDir) {
return true
}
}
return false
}
@@ -328,11 +383,25 @@ func dcnNameKey(goos, name string) string {
//
// A path cleanRel refuses is judged as sent (the sandbox refuses it anyway).
func dcnExcludedAsSent(excludes []string, root, rpath string) bool {
return dcnJudgedAsSent(root, rpath, func(rel string) bool { return dcnExcluded(excludes, rel) })
}
// dcnAgentAsSent is whether a path a host sent opens an agent's folder (by its
// spelling or through a link), judged as dcnExcludedAsSent judges excludes.
// dcnExcluded refuses the same paths; this one exists so the refusal can say
// why.
func dcnAgentAsSent(root, rpath string) bool {
return dcnJudgedAsSent(root, rpath, func(rel string) bool { return dcnInAgentDirOn(runtime.GOOS, rel) })
}
// dcnJudgedAsSent applies judge to what a host's path opens: the cleaned path
// relative to the root, then where it leads relative to the resolved root.
func dcnJudgedAsSent(root, rpath string, judge func(rel string) bool) bool {
joined, ok := cleanRel(root, rpath)
if !ok {
return dcnExcluded(excludes, rpath)
return judge(rpath)
}
if rel, err := filepath.Rel(filepath.Clean(root), joined); err != nil || dcnExcluded(excludes, rel) {
if rel, err := filepath.Rel(filepath.Clean(root), joined); err != nil || judge(rel) {
return true
}
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
@@ -343,7 +412,7 @@ func dcnExcludedAsSent(excludes []string, root, rpath string) bool {
if err != nil {
return true
}
return dcnExcluded(excludes, rel)
return judge(rel)
}
// dcnWhereItLeads is p with its deepest existing part's links resolved and the
@@ -428,6 +497,10 @@ func dcnLoadGrantMask(ws *websocket.Conn, dir, op string, storeID int64, rpath,
dcnRefuse(ws, dir, op, storeID, rpath, "op '"+mask+"' not in the store's granted mask")
return nil
}
if rpath != "" && dcnAgentAsSent(terms.Root, rpath) {
dcnRefuse(ws, dir, op, storeID, rpath, "path is in the local search agent's own folder (.agent), which no grant reaches")
return nil
}
if rpath != "" && dcnExcludedAsSent(terms.Excludes, terms.Root, rpath) {
dcnRefuse(ws, dir, op, storeID, rpath, "path is excluded by the grant")
return nil
+4
View File
@@ -142,6 +142,10 @@ func dcnCheckPlan(root string, excludes []string, moves []dcnMove) []dcnPlanProb
continue
}
seenTo[pathKey(dst)], seenFrom[pathKey(src)] = i, i
if dcnAgentAsSent(root, m.From) || dcnAgentAsSent(root, m.To) {
bad(i, m, "the local search agent's own folder (.agent) is never moved from or into")
continue
}
if dcnExcludedAsSent(excludes, root, m.From) {
bad(i, m, "source is excluded by the grant")
continue
+290
View File
@@ -0,0 +1,290 @@
package main
// Two things a host holding `read` on a folder must never reach through the
// file ops (list, stat, read_range), nor move with a plan:
// - what an exclude of several folders names. The Host's default excludes
// carry ".config/gcloud", and a pattern with a "/" was compared with one
// folder name at a time, so it matched nothing: a shared home folder
// served its gcloud credentials;
// - the local search agent's own folder, .agent, which holds the plaintext
// of every file the agent read, whatever the grant excludes.
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
// dcnDefaultExcludes is the exclude list the Host proposes for a new folder.
var dcnDefaultExcludes = []string{
".ssh", ".gnupg", ".aws", ".azure", ".config/gcloud", ".kube",
".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "*.keystore",
"id_rsa*", "id_ed25519*", "*.token", "credentials*", ".netrc",
".git-credentials", "*history",
}
// syncExcluding installs a read grant for the store with these excludes,
// through the real grant_sync op.
func (fx *dcnStoreFixture) syncExcluding(t *testing.T, excludes []string) {
t.Helper()
rec := signedRec(t, fx.priv, map[string]any{"kind": "dcn_store_grant", "class": "machine",
"root": fx.store, "mask": []string{"read"}, "excludes": excludes,
"expires": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339)})
payload, _ := json.Marshal(map[string]any{"store_id": 1, "terms_canonical": rec.TermsCanonical,
"signature": rec.Signature, "verify_pubkey": base64.StdEncoding.EncodeToString(fx.pub)})
_, why, exit := dcnRunOp(t, func(ws *websocket.Conn) {
handleDcnGrantSync(ws, fx.dir, []string{base64.StdEncoding.EncodeToString(payload)})
})
if exit != 0 {
t.Fatalf("grant_sync: exit %d: %s", exit, why)
}
}
func (fx *dcnStoreFixture) put(t *testing.T, rel, body string) {
t.Helper()
p := filepath.Join(fx.store, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
// listed is what dcn_list returns under prefix, and its exit code.
func (fx *dcnStoreFixture) listed(t *testing.T, prefix string) ([]string, int) {
t.Helper()
obj, _, exit := fx.op(t, handleDcnList, map[string]any{"prefix": prefix})
var paths []string
files, _ := obj["files"].([]any)
for _, f := range files {
if m, ok := f.(map[string]any); ok {
p, _ := m["path"].(string)
paths = append(paths, p)
}
}
return paths, exit
}
// A pattern with a "/" names that run of folders, wherever it sits in the
// path, each folder compared as the file system compares names. Judged per
// platform from any OS.
func TestDcnAMultiFolderExcludeMatchesThatRunAnywhere(t *testing.T) {
for _, c := range []struct {
goos, path string
want bool
}{
{"linux", ".config/gcloud/access_tokens.db", true},
{"linux", ".config/gcloud", true},
{"linux", "home/.config/gcloud/x", true},
{"linux", "a/b/.config/gcloud/configurations/config_default", true},
{"linux", ".config/gcloudx", false},
{"linux", ".config/gcloudx/access_tokens.db", false},
{"linux", ".configx/gcloud/x", false},
{"linux", ".config/other/gcloud/x", false},
{"linux", "gcloud/.config/x", false},
{"linux", ".config", false},
{"linux", ".config/settings.ini", false},
{"linux", ".Config/gcloud/x", false}, // another folder on Linux
{"darwin", ".Config/GCloud/access_tokens.db", true},
{"darwin", "home/.CONFIG/gcloud/x", true},
{"darwin", ".config/gcloudx/x", false},
{"windows", ".CONFIG/GCLOUD/access_tokens.db", true},
{"windows", "home/.config./gcloud /x", true},
{"windows", ".config/gcloudx/x", false},
// one-folder patterns as before
{"linux", "deep/.ssh/config", true},
{"linux", "certs/server.key", true},
{"linux", "notes/ssh-notes.txt", false},
{"windows", ".SSH/id_rsa", true},
} {
if got := dcnExcludedOn(c.goos, dcnDefaultExcludes, c.path); got != c.want {
t.Errorf("%s: %q excluded=%v, want %v", c.goos, c.path, got, c.want)
}
}
// A pattern's own slashes at either end, or doubled, name the same run.
for _, pat := range []string{".config/gcloud/", "/.config/gcloud", ".config//gcloud"} {
if !dcnExcludedOn("linux", []string{pat}, "home/.config/gcloud/x") {
t.Errorf("%q must exclude home/.config/gcloud/x", pat)
}
}
}
// The default exclude of gcloud's folder holds on every op a host sends, at the
// top of a home folder and inside one, and however the path is spelled.
func TestDcnTheDefaultGcloudExcludeHoldsOnEveryOp(t *testing.T) {
fx := newDcnStoreFixture(t)
fx.put(t, ".config/gcloud/access_tokens.db", "GCLOUD-TOKEN")
fx.put(t, "home/.config/gcloud/credentials.db", "NESTED-TOKEN")
fx.put(t, ".config/gcloudx/ok.txt", "NEAR-MISS")
fx.put(t, ".config/settings.ini", "SETTINGS")
fx.syncExcluding(t, dcnDefaultExcludes)
paths, exit := fx.listed(t, "")
if exit != 0 {
t.Fatalf("list: exit %d", exit)
}
have := map[string]bool{}
for _, p := range paths {
have[p] = true
if strings.Contains(p, ".config/gcloud/") {
t.Errorf("list returned %q, under an excluded folder", p)
}
}
for _, p := range []string{".config/gcloudx/ok.txt", ".config/settings.ini", "a.txt"} {
if !have[p] {
t.Errorf("list left out %q, which no exclude names (got %v)", p, paths)
}
}
if paths, exit := fx.listed(t, "home/.config"); exit != 0 || len(paths) != 0 {
t.Errorf("list under home/.config: exit %d, %v; want nothing", exit, paths)
}
for _, prefix := range []string{".config/gcloud", "home/.config/gcloud"} {
if _, exit := fx.listed(t, prefix); exit != 126 {
t.Errorf("list of %q: exit %d, want 126", prefix, exit)
}
}
for _, p := range []string{".config/gcloud", ".config/gcloud/access_tokens.db",
"home/.config/gcloud/credentials.db", " .config/gcloud/access_tokens.db",
"docs/../.config/gcloud/access_tokens.db"} {
if obj, _, exit := fx.op(t, handleDcnStat, map[string]any{"path": p}); exit != 126 {
t.Errorf("stat %q: exit %d %v, want 126", p, exit, obj)
}
if got, _, exit := fx.read(t, p); exit != 126 || strings.Contains(got, "TOKEN") {
t.Errorf("read_range %q: exit %d %q, want 126 and nothing", p, exit, got)
}
}
if got, why, exit := fx.read(t, ".config/gcloudx/ok.txt"); exit != 0 || got != "NEAR-MISS" {
t.Errorf("a folder only named like gcloud's must read: exit %d %q %s", exit, got, why)
}
if problems := dcnCheckPlan(fx.store, dcnDefaultExcludes, []dcnMove{
{From: ".config/gcloud/access_tokens.db", To: "out.db"}}); len(problems) == 0 {
t.Error("a move out of gcloud's folder must be refused")
}
if problems := dcnCheckPlan(fx.store, dcnDefaultExcludes, []dcnMove{
{From: "a.txt", To: "home/.config/gcloud/a.txt"}}); len(problems) == 0 {
t.Error("a move into gcloud's folder must be refused")
}
if err := os.Symlink(filepath.Join(".config", "gcloud"), filepath.Join(fx.store, "gc")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if got, _, exit := fx.read(t, "gc/access_tokens.db"); exit != 126 || got == "GCLOUD-TOKEN" {
t.Errorf("read through a link to gcloud's folder: exit %d %q, want 126", exit, got)
}
}
// The agent's folder is refused on every op a host sends: at the store's top,
// inside a folder another grant shares (where that store's agent keeps its
// own), through a link, and however it is spelled. The grant here excludes
// nothing, so the refusal is not the grant's to give or take.
func TestDcnTheAgentsFolderIsNeverServed(t *testing.T) {
fx := newDcnStoreFixture(t)
fx.put(t, ".agent/extract/ab/report.txt", "PLAINTEXT-OF-A-FILE")
fx.put(t, ".agent/manifest.yaml", "manifest")
fx.put(t, "proj/.agent/extract/x.txt", "NESTED-PLAINTEXT")
fx.put(t, "proj/readme.md", "README")
fx.syncExcluding(t, nil)
paths, exit := fx.listed(t, "")
if exit != 0 {
t.Fatalf("list: exit %d", exit)
}
have := map[string]bool{}
for _, p := range paths {
have[p] = true
if strings.Contains("/"+p, "/.agent/") {
t.Errorf("list returned %q, inside an agent's folder", p)
}
}
if !have["a.txt"] || !have["proj/readme.md"] {
t.Errorf("list left out the folder's own files: %v", paths)
}
for _, prefix := range []string{".agent", ".agent/extract", "proj/.agent"} {
if _, exit := fx.listed(t, prefix); exit != 126 {
t.Errorf("list of %q: exit %d, want 126", prefix, exit)
}
}
for _, p := range []string{".agent", ".agent/extract/ab/report.txt", ".agent/manifest.yaml",
"proj/.agent/extract/x.txt", " .agent/manifest.yaml", "docs/../.agent/extract/ab/report.txt"} {
if obj, _, exit := fx.op(t, handleDcnStat, map[string]any{"path": p}); exit != 126 {
t.Errorf("stat %q: exit %d %v, want 126", p, exit, obj)
}
if got, _, exit := fx.read(t, p); exit != 126 || strings.Contains(got, "PLAINTEXT") {
t.Errorf("read_range %q: exit %d %q, want 126 and nothing", p, exit, got)
}
}
for _, m := range []dcnMove{{From: ".agent/extract/ab/report.txt", To: "out.txt"},
{From: "a.txt", To: ".agent/a.txt"}, {From: "a.txt", To: "proj/.agent/a.txt"}} {
if problems := dcnCheckPlan(fx.store, nil, []dcnMove{m}); len(problems) == 0 {
t.Errorf("a move %q -> %q must be refused", m.From, m.To)
}
}
// The bastion's own report of a plan it applied still goes into
// .agent/plans, through the real plan op.
hn, _ := os.Hostname()
rec := signedRec(t, fx.priv, map[string]any{"kind": "dcn_move_plan", "store_id": 1, "plan_id": 41,
"server": hn, "root": fx.store, "archive": "Archive",
"moves": []map[string]any{{"from": "a.txt", "to": "Archive/a.txt"}},
"expires": time.Now().Add(time.Hour).UTC().Format(time.RFC3339)})
payload, _ := json.Marshal(map[string]any{"terms_canonical": rec.TermsCanonical,
"signature": rec.Signature, "mode": "apply"})
out, why, exit := dcnRunOp(t, func(ws *websocket.Conn) {
handleDcnPlanApply(ws, fx.dir, []string{"1", base64.StdEncoding.EncodeToString(payload)})
})
if exit != 0 || !strings.Contains(out, `"ok"`) {
t.Fatalf("plan apply: exit %d %s %s", exit, out, why)
}
if b, err := os.ReadFile(filepath.Join(fx.store, ".agent", "plans", "41.json")); err != nil ||
!strings.Contains(string(b), `"state": "ok"`) {
t.Fatalf("the plan's report was not written into .agent/plans: %q %v", b, err)
}
if err := os.Symlink(filepath.Join(".agent", "extract", "ab", "report.txt"), filepath.Join(fx.store, "notes.txt")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if err := os.Symlink(".agent", filepath.Join(fx.store, "ag")); err != nil {
t.Fatal(err)
}
for _, p := range []string{"notes.txt", "ag/extract/ab/report.txt", "ag/manifest.yaml"} {
if got, _, exit := fx.read(t, p); exit != 126 || strings.Contains(got, "PLAINTEXT") {
t.Errorf("read_range %q, a link into .agent: exit %d %q, want 126", p, exit, got)
}
}
if _, exit := fx.listed(t, "ag"); exit != 126 {
t.Errorf("list of a link to .agent: exit %d, want 126", exit)
}
}
// The agent's folder is named as the file system names it, and on every OS
// regardless of case, as the agent's own walker treats it: a ".Agent" left on
// a volume that ignores case is the agent's folder.
func TestDcnTheAgentsFolderIsMatchedHoweverTheNameIsCased(t *testing.T) {
for _, c := range []struct {
goos, path string
want bool
}{
{"linux", ".agent/extract/x", true},
{"linux", ".AGENT/extract/x", true},
{"darwin", ".Agent/vectors.db", true},
{"windows", ".AGENT/extract/x", true},
{"windows", ".agent./extract/x", true},
{"windows", ".agent /extract/x", true},
{"windows", ".agent::$INDEX_ALLOCATION/x", true},
{"linux", "deep/.agent", true},
{"linux", ".agentx/x", false},
{"linux", "agent/x", false},
{"linux", "notes/.agent-old/x", false},
{"darwin", "docs/readme.md", false},
} {
if got := dcnExcludedOn(c.goos, nil, c.path); got != c.want {
t.Errorf("%s: %q refused=%v, want %v", c.goos, c.path, got, c.want)
}
}
}
+10 -5
View File
@@ -68,17 +68,22 @@ type freeagentConfig struct {
// faManagedSanitizeMode is the sanitize mode the bastion writes into every
// manifest it deploys (faManifestText). Flag mode is the agent's rules pass
// only: it replaces e-mail addresses, phone numbers, card, bank and ID
// numbers, IP and MAC addresses and keys, and does not find names, street
// addresses or dates of birth.
// only (the agent's pii/detect.py): it replaces what a pattern can find
// (faSanitizeNotes["flag"] names each kind), and does not look for names,
// street addresses, dates of birth, ID numbers such as passport numbers, or
// bank account numbers other than IBANs.
const faManagedSanitizeMode = "flag"
// faSanitizeNotes says, for each mode a reply can carry, what personal data
// may still be in the text, in the words the host relays. A mode with no entry
// here is reported as not known to remove personal data.
var faSanitizeNotes = map[string]string{
"flag": "rules only: e-mail addresses, phone numbers, card, bank and ID numbers, IP and MAC addresses " +
"and keys were replaced; names, street addresses and dates of birth were not",
"flag": "rules only. Replaced: e-mail addresses, phone numbers, card numbers, US social security numbers, " +
"international bank account numbers (IBANs), US bank routing numbers written after a word like " +
"\"routing\", Bitcoin addresses that start with 1 or 3, IP and MAC addresses, private keys, and " +
"access keys and tokens of the kinds it knows. Other numbers that look like one of these can be " +
"replaced too. Not looked for, so they can still be in the text: names, street addresses, dates of " +
"birth, passport, licence and other ID numbers, other bank account numbers and other crypto addresses",
"sterilize": "",
"none": "the agent's manifest has no sanitize stage, so nothing in the text was replaced",
}
+10 -2
View File
@@ -330,8 +330,16 @@ func TestASanitizeStampNamesTheModeThatRan(t *testing.T) {
}
}
}
if !strings.Contains(faSanitizeNotes["flag"], "names") {
t.Fatal("the flag note must say names are left in")
// The agent's rules pass (pii/detect.py) does not look for these, so the
// note the host relays must say they can still be there.
for _, left := range []string{"names", "street addresses", "dates of birth", "passport",
"other bank account numbers", "other crypto addresses"} {
if !strings.Contains(faSanitizeNotes["flag"], left) {
t.Errorf("the flag note must say %s can still be in the text: %q", left, faSanitizeNotes["flag"])
}
}
if strings.Contains(faSanitizeNotes["flag"], "ID numbers, IP") || strings.Contains(faSanitizeNotes["flag"], "bank and ID") {
t.Errorf("the flag note says ID or bank numbers were replaced, which the rules pass does not do: %q", faSanitizeNotes["flag"])
}
}
+3 -2
View File
@@ -137,13 +137,14 @@ func landsInside(root, p string) bool {
return false
}
// excludedOnDisk is the fixture's excludes (".ssh", "*.key") judged
// excludedOnDisk is the fixture's excludes (".ssh", "*.key"), and the local
// search agent's folder (".agent", which no grant reaches), judged
// independently of dcnExcluded: on the path relative to the root after every
// link is followed, and on the text of the path the sandbox opens.
func excludedOnDisk(root, p string) bool {
check := func(rel string) bool {
for _, seg := range strings.Split(rel, "/") {
if seg == ".ssh" || strings.HasSuffix(seg, ".key") {
if seg == ".ssh" || strings.HasSuffix(seg, ".key") || strings.EqualFold(seg, ".agent") {
return true
}
}
+10 -8
View File
@@ -2,12 +2,13 @@ package main
// Git lane: Gitea through the Host.
//
// git.privatae.ai answers only platform/DMZ addresses now, so the user's LOCAL git
// cannot reach it. This bastion offers a loopback HTTP proxy for git; every local
// connection is spliced over the tunnel into ONE WebSocket on the host (/bastion-git,
// same bearer + mTLS binding as the other lanes) and the host relays it out the
// platform path to git.privatae.ai ONLY. The wire framing is the egress lane's
// ('D'+bytes / 'E' end) with the roles swapped, so wsConn is reused unchanged.
// This bastion offers a loopback HTTP proxy for the user's LOCAL git to reach
// git.privatae.ai through their host; every local connection is spliced over the
// tunnel into ONE WebSocket on the host (/bastion-git, same bearer + mTLS binding
// as the other lanes) and the host relays it on to the forge. Which addresses the
// forge itself answers is the forge's rule; nothing here sets or checks it. The
// wire framing is the egress lane's ('D'+bytes / 'E' end) with the roles swapped,
// so wsConn is reused unchanged.
//
// Setup for the user (printed by `privatae-bastion git-setup`):
// git config --global http.https://git.privatae.ai.proxy http://127.0.0.1:<port>
@@ -199,7 +200,8 @@ func gitCredentialReply(body []byte, authtype bool) []byte {
return []byte("username=" + user + "\npassword=" + pass + "\nquit=1\n")
}
// cmdGitSetup prints the two git config lines for every pairing.
// cmdGitSetup prints the three git config lines for every pairing: the proxy,
// the reset of the forge's helper chain, and the helper.
func cmdGitSetup() error {
all, err := loadPairings()
if err != nil {
@@ -208,7 +210,7 @@ func cmdGitSetup() error {
if len(all) == 0 {
return fmt.Errorf("no pairings — run `privatae-bastion pair <token>` first")
}
fmt.Println("git through your bastion (git.privatae.ai is reachable only this way):")
fmt.Println("git to git.privatae.ai through this bastion and your host:")
if len(all) > 1 {
fmt.Println("(several hosts are paired: pick ONE for git, and name it with --host in the helper line)")
}
+2 -2
View File
@@ -142,8 +142,8 @@ Usage:
let your hosts run sealed coding runs on this machine (Linux, as
root): how many at once, how big each is, which engines. Off by
default; each run has no network but a proxy to the internet
privatae-bastion git-setup print the two git config lines that route git.privatae.ai
through this bastion (the forge is reachable only this way)
privatae-bastion git-setup print the git config lines that route git.privatae.ai
through this bastion and your host
privatae-bastion git-credential git's credential helper — fetches your forge token from your
host on every git auth (git-setup resets the helper chain so no
other helper caches it)
+3 -3
View File
@@ -533,9 +533,9 @@ func runPairing(ctx context.Context, cfg *Config) error {
}()
// 2d. Git lane: loopback HTTP proxy for git.privatae.ai only.
// Each local connection = one WebSocket to the host, relayed out the PLATFORM
// path there (the forge answers platform/DMZ addresses only). No credential
// rides this lane; git presents its own via the git-credential helper below.
// Each local connection = one WebSocket to the host, which relays it on to
// the forge (gitlane.go). No credential rides this lane; git presents its
// own via the git-credential helper below.
if cfg.GitProxyAddr != "" {
if gitLn, err := net.Listen("tcp", cfg.GitProxyAddr); err != nil {
// Optional lane: a port collision must not take the tunnel, lanes and
+1 -1
View File
@@ -1 +1 @@
0.58.0
0.58.1
+11 -9
View File
@@ -121,13 +121,15 @@ The file:
needs. It cannot write to that folder. `backup unshare` takes it back, and
unpairing or revoking the new host takes back every folder shared with it.
This bastion does not bring up the new host. Running one on hardware you own
needs infrastructure that is not part of this program, and not built yet:
This bastion does not bring up the new host. A Host on hardware you own is a
C-Series Host ([A Host on this machine](host-on-this-machine.md)), and it can
restore a backup the lost host made, from a file:
- a DMZ cluster on your hardware: a Kubernetes cluster with the network
policy, admission and storage the host's pod expects;
- the operator that creates and runs a host's pod on that cluster;
- the host's container images, from a registry that cluster can pull from;
- the certificate authority that issues the host its identity, and the
services a host reaches when it starts, or replacements for them;
- a place the new host can fetch the backup from, as above.
1. Copy the backup and its key file (`<stamp>.tar.zst.age` and
`<stamp>.key.age`) out of the lost host's backup folder here.
2. In the C-Series Host's Backups app, under "Restore from a file", choose
the two together and give the lost host's recovery key. The page opens the
key file with it and seals the backup's key to the new Host; the recovery
key never leaves the page.
3. Tick the box and enter the code from your authenticator. When the backup
is from another host number, the page asks you first.
+3 -3
View File
@@ -14,9 +14,9 @@ Run the tests:
go test ./...
```
The suite covers all three platforms' parsing and judgement logic from a single
`go test` on any OS, because every platform-specific decision is kept in files
with no build tag and only the enumerators are tagged.
Most parsing and judgement logic is in files with no build tag and runs under
`go test` on any OS. Some platform parsers and the Linux sandbox are in tagged
files, and their tests run only on that OS.
The walls a host's input has to get through also have fuzz targets
(`client-go/fuzz_*_test.go`): the path sandbox for store reads and move
+18 -12
View File
@@ -1,6 +1,7 @@
# How each claim is tested
The claims are the points of the [security model](security-model.md).
The claims are the points of the [security model](security-model.md), and
some further ones that page does not list.
Each row names a test in `client-go/` that fails if the claim it sits under
stops being true. Where a claim, or part of one, has no such test, the row
@@ -16,7 +17,7 @@ says so. Those parts are enforced only by the code as written.
| | `service_reach_test.go`: `TestReachGrantSyncAndTheForwardWall` | Installing a reach grant is refused without an approval, for another host, or for another machine. `serviceReachAllowed`, the reach check a slot port runs, then admits the granted host to the granted server only; another server, another host or a revoked grant is refused. The check is called directly, not through the slot listener. |
| | `claims_unix_test.go`: `TestEachInferenceForwardAdmitsOnlyThePairingThatHoldsInference` | Through each forward's accept loop on an in-memory tunnel: a pairing without `inference` never reaches the upstream, and granting the family lets the next connection through, with no restart. |
| | `claims_unix_test.go`: `TestTheServiceSlotAdmitsOnlyWithTheFamilyAndAReachGrant` | Through the slot listener: a connection is refused with a reach grant but no `services` family, and with the family but no reach grant for that server; with both it reaches the server's socket. |
| | `claims_static_test.go`: `TestOnlyTheGatedForwardsListenInsideTheTunnel` | Every listener on the tunnel's network stack is one of the four gated forwards, each at one call site; only they are handed the stack, and it is otherwise only dialled out of. |
| | `claims_static_test.go`: `TestOnlyTheGatedForwardsListenInsideTheTunnel` | Every listener on the tunnel's network stack is one of the five gated forwards (three inference forwards, the service slot and coder runs), each at one call site; only they are handed the stack, and it is otherwise only dialled out of. |
| | not covered by a test | The reach check's own comparison of a stored grant's machine (`service_reach.go`, `serviceReachAllowed`). |
| Scoped tunnel | `claims_tunnel_test.go`: `TestTheTunnelReachesOnlyThePairedHost` | A WireGuard device configured the way a pairing configures it holds one peer, whose only allowed address is the host's /128, before and after an endpoint rotation. |
| | `claims_tunnel_test.go`: `TestATunnelAddressThatIsNotOneHostIsRefused` | A tunnel address that is a prefix, empty, zoned, unspecified, multicast or not an address is refused at tunnel start and at a rotation, and the device is left with no peer. |
@@ -55,7 +56,7 @@ says so. Those parts are enforced only by the code as written.
| No MCP endpoint | `claims_static_test.go`: `TestNoRouteOpOrDependencySpeaksMCP` | Every HTTP route on every listener is in a fixed table and none names MCP; no management op, import or module names MCP; no string in the code is a URL or route with an `/mcp` path, or names JSON-RPC. The agent clients' tool-server config files are named only in `mcpconfig.go`, which reads them, and nothing in the config reader imports or can reach a dialler, an HTTP client, a websocket or a process start. |
| | `claims_static_test.go`: `TestMCPReaderCheckCatchesADialAndAStrayBasename` | The check above reports a config reader that starts a process, imports `net/http` or reaches a dialler through another function, a Windows reader that starts a process, opens a socket, loads a DLL by name or opens a remote registry, and a second file that names a client's config file, the servers key, an `/mcp` URL or an `/mcp` route. |
| Grants are verified here | `anchor_test.go`: `TestPairingGrantSyncWalls` | A pairing grant signed by another key, for another machine, naming another anchor, or expired is refused, and the pairing stays pending. |
| | `fuzz_grants_test.go`: `FuzzAChangedSignedByteNeverVerifies` | For every kind of signed record, changing any byte of the record or its signature makes it fail to verify. |
| | `fuzz_grants_test.go`: `FuzzAChangedSignedByteNeverVerifies` | For each of nine kinds of signed record (pairing grant, pairing revoke, anchor consent, management grant, store grant, move plan, reach grant, op run and op grant), changing any byte of the record or its signature makes it fail to verify. |
| | `session_test.go`: `TestSessionPinsTheHostKeyWithoutAStoreGrant` | The first key offered is pinned; a different key later is refused and the pin is kept. |
| | `anchor_test.go`: `TestAnchorVerifyKeyPinsFromDCNAndRefusesRepin` | The anchor's verify key cannot be re-pinned to a different key. |
| | `session_test.go`: `TestSessionRefusedWithoutGrantIs126` | An op the grant both allows and denies, or a grant that denies `*`, is refused. |
@@ -72,7 +73,7 @@ says so. Those parts are enforced only by the code as written.
| | `fuzz_observe_test.go`: `FuzzUnitNameIsSystemdsAlphabet` | Whatever the input, a unit name that passes is in systemd's unit-name alphabet, at most 255 characters, ends in a unit type, does not start with `-`, `.` or `@`, and is the input itself or the input with `.service`. |
| | `claims_unix_test.go`: `TestTheAllowlistedOpIgnoresTheCallersArguments` | `uptime`, sent through the management lane with hostile arguments, runs its program with none. |
| | `claims_static_test.go`: `TestTheAllowlistedOpRunsItsTableArgvOnly` | The allowlisted table is exactly `uptime` with no arguments, and the one command the lane starts is built from the table's argv alone. |
| | `claims_static_test.go`: `TestFileHandlersStartNoShell` | Nothing a context, store or backup handler can reach runs a shell; the one process any of them can start is the local search agent's Python, which `dcn_grant_sync` brings up. |
| | `claims_static_test.go`: `TestFileHandlersStartNoShell` | Leaving text extraction (`ctx_extract`) aside, nothing a context, store or backup handler can reach runs a shell, and the one process any of them can start is the local search agent's Python, which `dcn_grant_sync` brings up. |
| Approval on the machine, once enrolled | `stepup_test.go`: `TestStepupVerifyBurnAndApprovals` | With no factor enrolled the gate passes; once enrolled, a code is used once, and an approval passes only for the exact terms it was issued for, and only once. |
| | `modelswap_test.go`: `TestAnApprovalIsSingleUse` | A spent approval does not fund a second model download. |
| | `modelswap_test.go`: `TestUnenrolledMachineCannotHaveAModelPulled` | With no factor enrolled, a model pull is refused. |
@@ -166,7 +167,7 @@ says so. Those parts are enforced only by the code as written.
| | `service_reach_test.go`: `TestReachGrantSyncAndTheForwardWall` | Installing a reach grant is refused without an approval, for another host, or for another machine. The reach check then admits the granted host to the granted server only, and a revoked grant admits nothing. Called directly, not through the slot listener. |
| | `claims_unix_test.go`: `TestTheServiceSlotAdmitsOnlyWithTheFamilyAndAReachGrant` | Through the slot listener: each connection is checked, and one without a reach grant for that server, or without the `services` family, never reaches the server's socket. |
| | not covered by a test | The reach check's own comparison of a stored grant's machine. |
| Egress for someone else reaches the public internet, never this machine | `coders_linux_test.go`: `TestCoderEgressRefusesEveryInternalClass` | A coder run's proxy refuses every internal class (loopback, RFC 1918, CGNAT, link-local and metadata, ULA, IPv4-mapped, IPv4-compatible, IPv4-translated, NAT64, 6to4, unspecified, 0/8, 198.18/15, 240/4, multicast, fec0::/10), by address and by a name with one such answer among public ones, and `localhost` names and names that do not resolve. |
| Egress for someone else reaches the public internet, and nothing on this machine or your network that you did not name | `coders_linux_test.go`: `TestCoderEgressRefusesEveryInternalClass` | A coder run's proxy refuses every internal class (loopback, RFC 1918, CGNAT, link-local and metadata, ULA, IPv4-mapped, IPv4-compatible, IPv4-translated, NAT64, 6to4, unspecified, 0/8, 198.18/15, 240/4, multicast, fec0::/10), by address and by a name with one such answer among public ones, and `localhost` names and names that do not resolve. |
| | `coders_linux_test.go`: `TestCoderEgressRefusesThisMachinesOwnPublicAddress` | A public address on this machine's interface is refused in every spelling (IPv4-mapped, NAT64, 6to4) and through a name; a neighbour on the same subnet passes; `allow_lan` is the one exception. |
| | `coders_linux_test.go`: `TestCoderEgressLocalAddressesAreReread` | An address the machine gains after start is refused; interfaces that cannot be read refuse. |
| | `coders_linux_test.go`: `TestCoderEgressDialsTheVettedAddressAndHonoursAllowLAN` | The proxy returns the vetted addresses, IPv4 first, never the name; a LAN host passes only when named in `allow_lan`, and an entry with a port only on that port. |
@@ -181,9 +182,9 @@ says so. Those parts are enforced only by the code as written.
| | `egress_network_test.go`: `TestLocalEgressRefusesTheLANAndTheMesh` | On a local machine the egress lane refuses the network around it as an anchored one does: private, ULA, CGNAT and benchmarking addresses, a NAT64 address for one, and a name with any one such answer, each answered 403 through the lane with nothing dialled; a public name passes at its vetted address. |
| | `ownaddr_inside_linux_test.go`: `TestNetworksInsideThisMachineAreThisMachine` | In a private network namespace with two bridges whose only port is a veth (one with an IPv4 and an IPv6 range, one with a public range), a bridge whose port is a dummy, a bridge with no port, a veth routed to directly, a range routed through a veth with this machine's own address on it as the gateway, a veth whose only default route is in another table, a bridge over a VLAN, a route over a dummy link, the network on three veths that carry this machine's main-table default routes (an IPv4 multipath default over two, an IPv6 default over the third: this machine as a container), addresses through those defaults' gateways and a route through a gateway on the dummy-port bridge (a LAN behind a router VM): the real route lookup reads the first seven as this machine and the rest as leaving; a local machine's egress lane refuses the inside addresses, by address and by name, as this machine, refuses the private addresses beyond them as its network, and dials the public ones; a coder run's proxy refuses the public range on an inside network and dials a public address through the gateway; the backup lanes allow the IPv4 and IPv6 addresses through the gateway. |
| | `ownaddr_inside_linux_test.go`: `TestInsideMachineReadsTheLinkAndABridgesPorts` | A veth, and a bridge whose every port is a veth, a tap or a dummy (or that has none), keep a packet on this machine; a bridge with a network card, a VLAN or a bridge among its ports, a network card, loopback, a VPN, a tunnel and a macvlan do not. |
| | `egress_local_test.go`: `TestLocalEgressLaneNeverDialsThisMachine` | Through the lane on a local machine with a real dialer: a request for a loopback listener gets 403 over CONNECT and plain HTTP and never reaches it; a LAN name is dialled at its vetted address; a dual-stack name falls back to its second address. |
| | `egress_local_test.go`: `TestLocalEgressLaneNeverDialsThisMachine` | Through the lane on a local machine with a real dialer: a request for a loopback listener gets 403 over CONNECT and plain HTTP and never reaches it; a public name is dialled at its vetted address; a dual-stack name falls back to its second address. |
| | `egress_dial_test.go`: `TestDialEachDoesNotWaitOutAnAddressThatGoesNowhere` | The dial both egress lanes share tries only the vetted addresses, in order; an address that never answers holds up the next for well under its share of the budget, and is cancelled once the next answers. |
| | `egress_relay_test.go`: `TestARefusingHostCannotSpinTheEgressWorkers` | A Host that answers every parked egress worker with a job this machine refuses (a request for `localhost`, a line that is no request) gets at most four workers a second back, not a dial loop, and nothing is dialled out. |
| | `egress_relay_test.go`: `TestARefusingHostCannotSpinTheEgressWorkers` | A Host that answers every parked egress worker with a job this machine refuses (a request for `localhost`, a line that is no request) gets at most 16 workers back in two seconds, not a dial loop, and nothing is dialled out. |
| | `egress_relay_test.go`: `TestEgressWorkersParkAgainAtOnceAfterAJobTheyTook` | A worker that took a job parks again at once: 40 accepted jobs through the four workers are each answered 200 well inside the 10 s one wait per job would take. |
| | `egress_local_test.go`: `TestLocalModelsSwitchLeavesNoEgressRouteToOllama` | On a local machine with `models` switched off for the host, the ollama forward filters, and the egress lane refuses `/api/pull` to ollama on loopback, `localhost` and this machine's LAN address without a dial. |
| | `egress_local_test.go`: `TestAnchoredEgressStillRefusesTheLAN` | An anchored machine's egress lane still refuses the LAN addresses a local machine reaches, with its own wording, and passes the public internet. |
@@ -203,7 +204,7 @@ says so. Those parts are enforced only by the code as written.
| A pairing code pins the Host's CA by its fingerprint | `pair_code_linux_test.go`: `TestAPairingCodePinsTheHostCAByFingerprint` | A pairing code whose enroll port presents no CA, another CA, a CA under another fingerprint, a leaf another CA signed, or a leaf without the name `host.cseries.internal` is refused before the token is spent, and nothing is saved. |
| | `pair_code_linux_test.go`: `TestAPairingCodePairsAsTheHostCAFileDoes` | A good code makes the same TCP pairing `--redeem-url` and `--host-ca` make, with that CA pinned; the same code again is "already paired" (exit 3) and changes nothing; a Host of the same name under another CA is a plain refusal. |
| | `pair_code_linux_test.go`: `TestAPairingCodeIsReadStrictly` | A code that is garbled, another version, or has an extra, missing or malformed field is refused, and no refusal repeats the token. |
| | `pair_code_linux_test.go`: `TestACodeTheHostMadeDecodesAndPins` | A code made by the C-Series Host's own `pairing_code` decodes to its address and token, and its fingerprint is the one `caSHA256` computes from the same CA. |
| | `pair_code_linux_test.go`: `TestACodeTheHostMadeDecodesAndPins` | A recorded code from the C-Series Host's `pairing_code` decodes to its address and token, and its fingerprint is the one `caSHA256` computes from the same CA. |
| | `pair_code_linux_test.go`: `TestAPairingCodeWithAKeyGivenTwiceIsRefused` | A code that names a field twice is refused. |
| | `pair_code_linux_test.go`: `TestThePinnedChainTakesOnlyAValidCAWithTheFingerprint` | A certificate with the fingerprint that is not a CA, an expired CA, or a chain of the leaf alone is refused; the pinned CA after other certificates is the one kept. Called directly, not through TLS. |
| | `pair_code_linux_test.go`: `TestHostCAFileWithASameNamedHostUnderAnotherCAIsNotAlreadyPaired` | With `--host-ca` too, a Host of the same name under another CA is a plain refusal, not exit 3, and its token is not spent; the same Host under its own CA is exit 3. |
@@ -236,10 +237,10 @@ says so. Those parts are enforced only by the code as written.
| | `rootexec_test.go`: `TestRootExecStopsWhenAWallFallsMidRun` | Through the handler: a command already running is killed within one recheck when its grant is revoked or expires, `session` is switched off here, or the pairing is quarantined, and answered with 126 and a J that says why; while every wall holds, the recheck leaves it alone. |
| | `rootexec_test.go`: `TestRootExecDeadlineKillsAChildThatIgnoresSIGTERM` | Through the handler with the real `/bin/sh`: at the deadline a child that ignores SIGTERM, holding the output open or not, is gone when the answer arrives, within the deadline and the kill grace. Every signal goes through a fence that admits only the process group the test started. |
| | `rootexec_test.go`: `TestRootExecBadCommandAndTranscriptFailureAreAudited` | A bad command, a bad session id, and a transcript that cannot be opened each leave an audit row with the command (or its size and sha256) and exit 1; none starts. |
| | `rootexec_watch_linux_test.go`: `TestRootExecWatchKernel` | In a private network namespace, through the handler with the real `/bin/sh`, nftables and a cgroup: a command's connections to outside addresses (IPv4 and IPv6, TCP and UDP) are reported in `untrusted_connections`; loopback, the machine's own address and listed platform ranges are not, though they stay in the audit row marked own; a grandchild's connections are caught; what a command reached before its deadline killed it is reported; no table or cgroup is left after any of them, and one left by a dead bastion is removed by the next sweep, with any process in it moved to its parent. |
| | `rootexec_watch_linux_test.go`: `TestRootExecWatchKernel` | In a private network namespace, through the handler with the real `/bin/sh`, nftables and a cgroup: a command's connections to outside addresses (IPv4 and IPv6, TCP and UDP) are reported in `untrusted_connections`; loopback (except a port listed as a relay), the machine's own address and listed platform ranges are not, though they stay in the audit row marked own; a grandchild's connections are caught; what a command reached before its deadline killed it is reported; no table or cgroup is left after any of them, and one left by a dead bastion is removed by the next sweep, with any process in it moved to its parent. |
| | `rootexec_watch_test.go`: `TestRootExecJCarriesOnlyOutsideConnections` | The J frame lists only the destinations outside the platform list (addresses, CIDRs and resolved names), the stop row lists every one and counts the outside ones, the transcript records them; with only loopback and own hosts the field is absent. |
| | `rootexec_watch_test.go`: `TestRootExecUnwatchedCountsAsOutside` | A command whose connections could not be watched is reported with one untrusted entry that says why. |
| | `rootexec_watch_test.go`: `TestRootExecOwnHostsList` | No list means only loopback is own; a broken list or a bad entry reads as empty; a name that does not resolve adds nothing; the host's list is capped and the rest counted. |
| | `rootexec_watch_test.go`: `TestRootExecOwnHostsList` | No list means only loopback is own; a broken list or a bad entry reads as empty; a name that does not resolve adds nothing; the outside connections reported are capped and the rest counted. |
| | `claims_unix_test.go`: `TestAMemberWithoutTheSessionFamilyGetsNoTerminal` | Through the terminal handler, on an anchored machine: a host other than the anchor without the `session` family is refused and leaves no transcript; once the family is granted the same approval opens the terminal. |
| | `claims_unix_test.go`: `TestAnAnchoredTerminalApprovalOpensOneSessionOnce` | Through the handler: an anchored machine's terminal approval is refused for another session, opens its own, and is refused when used again. |
| | `claims_unix_test.go`: `TestARootTerminalNeedsTheBastionToRunAsRoot` | Through the handler: with a factor enrolled and an approval for that session, a bastion not running as root refuses a root terminal, and the same approval opens it once the bastion runs as root. |
@@ -253,7 +254,7 @@ says so. Those parts are enforced only by the code as written.
| On macOS and Windows, a networked bastion is the system's service | `sysservice_test.go`: `TestLaunchdPlistRunsTheDaemonAsRootAtBootAndKeepsItAlive` | The launchd job is well-formed and runs the program as root at boot, with root's home, and launchd restarts it whenever it exits. |
| | `sysservice_test.go`: `TestBinaryChainMustBeRootOnly` | On macOS the service is refused for a program a non-root user owns, that a group or everyone can write, that is a symlink, or that sits under such a directory. Run against a stand-in for the disk, not on a Mac. |
| | `sysservice_test.go`: `TestWindowsServiceBinaryMustLiveUnderProgramFiles` | On Windows the service is refused for a program outside Program Files, including the per-user install folder. |
| | `sysservice_test.go`: `TestServiceGuardRefusesAUserspaceBastionBesideTheService` | With the service installed, `pair`, `run` and `status` from an account without machine authority are refused; an administrator, and a machine with no service, are not. |
| | `sysservice_test.go`: `TestServiceGuardRefusesAUserspaceBastionBesideTheService` | With the service installed, `pair`, `run`, `status` and `switch` from an account without machine authority are refused; an administrator, and a machine with no service, are not. |
| | `sysservice_test.go`: `TestServiceModeOpensNothingOnTheDesktop` | As a Windows service, the tray, the blocking dialog and the detached relaunch after an update each return before doing anything. Read from the source, not run. |
| | `runlock_unix_test.go`: `TestARunBesideTheMacServiceIsRefusedBeforeItStarts` | With the macOS daemon installed, a second `run` is refused while the first holds the lock in the state folder, and may run once it is gone; with no daemon nothing is locked. Run against the real lock on Linux; the macOS wiring (`runguard_darwin.go`) is not run off a Mac. |
| | not covered by a test | That launchd and the Windows service manager actually restart the bastion after it exits (`sysservice_darwin.go`, `sysservice_windows.go`); neither runs off its own platform. |
@@ -299,7 +300,12 @@ says so. Those parts are enforced only by the code as written.
| | `backuproot_test.go`: `TestBackupRootsAreEachHostsOwn` | A backup op is refused while the host's backup folder is the same as, inside, or contains another paired host's, also through a link. |
| | `backuproot_test.go`: `TestBackupRootNeverOverlapsTheBastionsOwnState` | A backup folder that is or contains the bastion's configuration folder, keys or backup records, or sits in the configuration folder outside `backups/`, is refused, and none of them can be shared; the default folder is accepted. |
| No delete op on shared stores | `dcn_plan_test.go`: `TestDcnPlanCheckApplyVerify` | A move plan that escapes the store, targets an existing path, or touches an excluded file is refused whole; the op list carries the plan op and no `dcn_delete` or `dcn_remove`. |
| | `fuzz_paths_unix_test.go`: `FuzzMovePlanStaysInsideTheRoot` | Any move the plan check passes stays inside the store, targets a path that does not exist yet, and avoids excluded paths. |
| | `fuzz_paths_unix_test.go`: `FuzzMovePlanStaysInsideTheRoot` | Any move the plan check passes stays inside the store, targets a path that does not exist yet, and avoids excluded paths and the search agent's folder. |
| A shared folder serves only what its grant leaves in | `dcn_private_test.go`: `TestDcnTheDefaultGcloudExcludeHoldsOnEveryOp` | Through list, stat and read_range on a store granted with the Host's default excludes: nothing in `.config/gcloud`, at the top of the store or inside `home/`, is listed, stated or read, as written, with whitespace, through a `..` or through a link; a move out of or into it is refused; `.config/gcloudx` and the rest of `.config` still read. |
| | `dcn_private_test.go`: `TestDcnAMultiFolderExcludeMatchesThatRunAnywhere` | A pattern with a `/` matches that run of folders at any depth, with names compared as Linux, macOS and Windows compare them (case, and Windows' trailing dots and spaces); a near miss (`.config/gcloudx`, `.configx/gcloud`, the two folders apart or in the other order) is not excluded; slashes at either end or doubled name the same run; one-folder patterns match as before. Judged per platform from any OS. |
| | `fuzz_paths_unix_test.go`: `FuzzAnExcludedPathIsExcludedHoweverItIsSpelled` | Any path the sandbox accepts and the excludes pass opens neither an excluded name nor the search agent's folder, by its spelling or through a link inside the store. |
| | `dcn_private_test.go`: `TestDcnTheAgentsFolderIsNeverServed` | With a grant that excludes nothing: no path in `.agent` at the top of the store, or in one inside a folder of it, is listed, stated or read, as written, with whitespace, through a `..`, or through a link to the folder or to a file in it; a move out of or into one is refused; the bastion's own report of a plan it applied, through the real plan op, is still written to `.agent/plans`. |
| | `dcn_private_test.go`: `TestDcnTheAgentsFolderIsMatchedHoweverTheNameIsCased` | `.agent` is refused in any case on every OS, and on Windows with trailing dots, spaces or a stream name; `.agentx`, `agent` and `.agent-old` are not. Judged per platform from any OS. |
| Verified releases | `release_sign_test.go`: `TestATamperedManifestIsRefused` | A manifest changed after signing is refused. |
| | `release_sign_test.go`: `TestAManifestSignedByAnotherKeyIsRefused` | A manifest signed by a key the build does not pin is refused. |
| | `release_sign_test.go`: `TestAnUnsignedManifestIsNotRead` | A manifest with no signature reaches neither the updater nor the local page. |
+8 -2
View File
@@ -28,16 +28,22 @@
| `ops [--host <host>]` | The ops your host wrote that this machine holds, and the standing op grants. |
| `ops drop <grant> [--host <host>]` | Take back a standing op grant. The host is told. |
| `coders set [--max N] [--mem MB] [--cpus F] [--engines a,b\|all] [--allow-lan host[:port],...] [--image-prefix <registry/path/>\|default]` | Size this machine for coding runs your hosts place here: how many at once, memory and CPUs per run, which engines, LAN hosts a run may reach, and where run images may come from (`<prefix>harness-<engine>@sha256:...`; default `git.privatae.ai/privatae-harness/`). Does not switch them on. |
| `coders on` / `coders off` | Allow coding runs here (Linux, bastion running as root), filling unset sizes with defaults; or stop new ones. Off by default. Each run is a container with no network of its own; it reaches the public internet through this bastion and never this machine or its LAN. |
| `coders on` / `coders off` | Allow coding runs here (Linux, bastion running as root), filling unset sizes with defaults; or stop new ones. Off by default. Each run is a container with no network of its own. Through this bastion it reaches the public internet, and never this machine or its LAN unless you named that host with `--allow-lan`. |
| `coders drop [--host <host>]` | Take back the standing coder grant of every host, or one, and stop their runs. |
| `coders status [--json]` | The coder settings, grants and runs on this machine. |
| `quarantine clear <host>` | Reopen a host's write lanes that this machine closed after repeated refusals. |
| `git-setup` | Print the `git config` lines that route `git.privatae.ai` through this bastion. |
| `git-credential` | Git credential helper. Fetches your forge token from your host on every auth. |
| `unpair <host>` | Tear down one pairing. Context store and DCN state are left in place. |
| `set-mode <local\|networked>` | Switch modes. Root required. |
| `set-mode <local\|networked>` | Switch modes. `networked` needs root; `local` is refused while several hosts are paired or the machine is anchored. |
| `service install\|uninstall\|status` | On macOS and Windows, run the bastion as the system's own service. `install` and `uninstall` need machine authority. |
| `claim-anchor [--owner <name>] [--host <host>] [<token>]` | Networked mode: make a host the anchor. No token when the host is already paired here. Also `--rotate <token>`, `--repin`. Root required. |
| `release-anchor --host <host> (--host-ca <pem> \| --force)` | Let go of an anchor whose Host is gone: unpair it and set its state aside, so a new Host can be anchored. `--host-ca` is the new Host's CA. Root required. |
| `claim-owner <name>` | State who owns this machine. Root required. Optional; grants nothing. |
| `root-exec grant [--host <host>] --for <duration>` / `root-exec revoke` | Let the anchor host run commands as root here, with the code from the authenticator enrolled here, until a time you choose (30 days at most); or take it back. `root-exec` alone shows the grant held. Granting needs root. |
| `switch <host> <family> on\|off` / `switch --list` | Switch a capability family off for one host on this machine, or back on; `--list` shows every host's families. Switching needs root. |
| `approvals-owner --sid <SID> [--replace]` | Windows, beside the system service: name the one account whose tray icon may list and answer approvals. Administrator only. |
| `tray` | Windows, beside the system service: your icon and approvals window, started at every sign-in. |
| `observe` | Print the agent, automation and scheduler snapshot the next check-in would carry. |
| `observe:baseline [--force]` | Record what is normal on this machine. Administrator only. |
| `observe:baseline:show` | Print the baseline this machine holds. |
+5 -4
View File
@@ -2,7 +2,8 @@
`privatae-bastion ui` opens a page served by the running daemon on `127.0.0.1`
and reachable from nowhere else: pairings, the anchor, containment state, marks
on automated programs, the dry-run policy, and pending approvals. On Windows
a tray icon shows a one-line state and offers four actions: open what is
waiting for your approval, open the console, open your context page, and stop
the bastion.
on automated programs, the dry-run policy, pending approvals, set-up, what this
machine has given away (where you switch a host's capabilities off), and a
disk map. On Windows a tray icon shows a one-line state. Run as you, it opens
what is waiting for your approval, the console and your context page, and can
stop the bastion. Beside the system service it opens approvals only.
+13 -5
View File
@@ -13,7 +13,11 @@ your machine, never on the platform.
Signature-verified store grants for folders you share. The host's verify key is
pinned on first sync (trust on first use) and a later key change is refused.
Every read is sandboxed to the granted path, walks no symlinks out of it, and is
audited on this machine. The vocabulary is reads plus **one write lane**: a
audited on this machine. The grant's excludes hold on every read and move: a
pattern of one name (`.ssh`, `*.key`) matches that name at any depth, and a
pattern with a `/` (`.config/gcloud`) matches that run of folders at any depth.
The bastion agent's own folder, `.agent`, which holds the plaintext of every
file the agent read, is never served or moved, whatever the grant excludes. The vocabulary is reads plus **one write lane**: a
signed move plan that you confirmed on this machine, which archives files and
never deletes them. Dropping a grant removes the grant, not your files.
**No delete op exists.**
@@ -23,7 +27,11 @@ results, read ranges and catalog cards come from the agent's sanitized text,
and each reply says how that text was sanitized: `sanitize_mode` is the mode
in the agent's manifest, `sanitized` is true only for `sterilize`, and
`sanitize_note` says what the mode leaves in. The bastion deploys the agent in
`flag` mode, which replaces e-mail addresses, phone numbers, card, bank and ID
numbers, IP and MAC addresses and keys, and does **not** find names, street
addresses or dates of birth. So a Host reading your folder through search
sees those.
`flag` mode, which replaces only what a pattern can find: e-mail addresses,
phone numbers, card numbers, US social security numbers, IBANs, US bank
routing numbers written after a word like "routing", Bitcoin addresses that
start with 1 or 3, IP and MAC addresses, private keys, and access keys and
tokens of the kinds it knows. It does **not** look for names, street
addresses, dates of birth, passport, licence and other ID numbers, other bank
account numbers or other crypto addresses. So a Host reading your folder
through search sees those.
+5 -4
View File
@@ -62,10 +62,11 @@ carries a tag of the named repository.
A Host with no platform that runs in a VM on this machine shares no kernel and
no directory with it. A C-Series Host
([Privatae/cseries](https://github.com/Privatae/cseries)) is one: it runs in
its own Linux virtual machine. Its installer, `npx cseries`, forwards two ports
of the VM to this machine's loopback, installs this bastion as the machine's
system service, and on every pairing runs these in one elevated step (on
Windows, from an Administrator PowerShell instead of `sudo -H`):
its own Linux virtual machine. Its installer, `npx cseries`, forwards three
ports of the VM (8800, 8810, 8811) to this machine's loopback, installs this
bastion as the machine's system service, and on every pairing runs these in
one elevated step (on Windows, from an Administrator PowerShell instead of
`sudo -H`):
```sh
# 1. The service's state, networked.
+15 -2
View File
@@ -20,8 +20,21 @@ signed in, and only the machine's administrator can stop it:
On macOS and Windows, `privatae-bastion service install|uninstall|status` does
the same by hand. An administrator's commands (`sudo -H …` on macOS, an
Administrator terminal on Windows) act on the service's state. From an account
that does not administer the machine, `pair`, `run` and `status` are refused,
so that account does not end up with a second, empty bastion of its own.
that does not administer the machine, `pair`, `run`, `status` and `switch` are
refused, so that account does not end up with a second, empty bastion of its
own.
A local install runs as you. On Windows the installer registers a logon task
that starts it each time you sign in.
A Cloud Host's "Set up a bastion" card gives one line that installs, pairs and
starts the bastion: `curl -fsSL https://privatae.ai/bastion/install.sh | PRIVATAE_PAIR_CODE='skp_…' sh`
on macOS and Linux, `& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Pair 'skp_…'`
on Windows. Installed as you, it then runs as your systemd user unit
`privatae-bastion`, your LaunchAgent `ai.privatae.bastion.user`, or the logon
task. Where it cannot use one, the installer starts the bastion without it and
says how long it will run: on Linux until the machine restarts, on Windows
until you sign out.
Release binaries are served from `https://dl.privatae.ai`. The release manifest
(`version.json`, read by the installers at `https://dl.privatae.ai/version.json`
+13 -12
View File
@@ -3,27 +3,28 @@
## Tunnel
A userspace WireGuard tunnel (gVisor netstack) scoped so this machine can reach
**only its own host**. No TUN device is created and `run` does not need root.
The tunnel is dialled from this machine. Inside it, the bastion accepts on
exactly three addresses, all under the `inference` capability family: a
forward to a local ollama (port 11434), a forward to an OpenAI-compatible
server when one is configured (port 18080), and a forward to a local PII
sterilizer when one is configured. Nothing else listens.
The tunnel is dialled from this machine. Its WireGuard UDP port, on every
address at a port the system picks when the tunnel comes up, drops every
packet that does not come from the host's key. Inside the tunnel, the
bastion accepts only on gated forwards: ollama (11434), an OpenAI-compatible server (18080) and a PII
sterilizer (18081) under the `inference` capability family, the last two only
when configured; one port per service-slot server (18100-18199); and one per
coder run (18200-18299). Nothing else listens.
## Git lane
A loopback HTTP proxy plus a `git-credential` helper, so your local `git` reaches
`git.privatae.ai` through your host. The forge answers platform addresses only,
so this lane is the way in. `privatae-bastion git-setup` prints the three
config lines: the proxy, a reset of the credential-helper chain for that URL
`git.privatae.ai` through your host. `privatae-bastion git-setup` prints the
three config lines: the proxy, a reset of the credential-helper chain for that URL
(so no other helper caches your token on this machine), and the helper itself.
## Worker lanes
Outbound-only reverse relays to the host: egress, management, harness, and
session. The management lane runs a **fixed-argv allowlist**. Ops that touch
your files are dedicated Go handlers with no shell. Of those file handlers,
the one that runs a program is text extraction: it invokes a local tool named
in a fixed table
your files are dedicated Go handlers with no shell. Two of them start a
program: text extraction invokes a local tool named in a fixed table
(`pdftotext`, `ffprobe`, and the like) from a fixed argv template, and the
caller supplies only the tool's name and the document.
caller supplies only the tool's name and the document; `dcn_grant_sync`
starts the local search agent's Python.
## Session lane
An interactive terminal that runs on this machine and streams to the host over
+5
View File
@@ -38,9 +38,14 @@ deploy/
release-sign/ the signing tool: runs where the release key is, never here
release-verify.sh check a manifest's signature against the pinned keys
verify-release.sh rebuild a published release and compare its digests
release-assets.sh rebuild a release from its tag and check it against the signed manifest
staticcheck.sh staticcheck on linux, darwin and windows
fuzz.sh run every fuzz target for a bounded time
check-claims.sh check that every test the security table cites exists
docs/ reference pages, listed in docs/README.md
scripts/ the check that no person's data is in the tree
.gitea/workflows/ CI on git.privatae.ai
.github/workflows/ CI and the release on GitHub
LICENSE GNU GPL v3
```
+26 -10
View File
@@ -1,10 +1,15 @@
# Security model
- **The bastion dials out.** Every lane is a reverse connection from this
machine to the host. No port is opened on an externally reachable interface;
the control ports and console bind loopback only, and the optional
memory-mirror forwarder binds loopback plus the replica container's private
gateway address, with every accept source-checked.
machine to the host. The only ports open on an outside interface are the
tunnels' WireGuard UDP ports: one for each host paired over a tunnel, on
every IPv4 and IPv6 address, at a port the system picks when that tunnel
comes up. WireGuard drops every packet on one that does not come from the
paired host's key; the only reply it ever sends anyone else is a cookie
under a flood of handshakes, and only to a sender that already knows that
tunnel's public key. Every other port the bastion opens binds loopback, except the
optional memory-mirror forwarder, which also binds the replica container's
private gateway address, with every accept source-checked.
- **Inside the tunnel, only gated listeners.** The host can connect to up to
three inference forwards (local ollama, an OpenAI-compatible server, a PII
sterilizer), each a proxy to a service you run, on loopback by default and
@@ -132,8 +137,8 @@
- **A server in the service slot has no network.** The host's tunnel is its
only way in, and it cannot reach this machine, your network or the
internet.
- **Egress for someone else reaches the public internet, never this
machine.** A coder run's proxy, always, and the egress lane on an anchored
- **Egress for someone else reaches the public internet, and nothing on
this machine or your network that you did not name.** A coder run's proxy, always, and the egress lane on an anchored
machine refuse loopback, link-local (the cloud metadata address included),
private, CGNAT and reserved addresses, the deprecated IPv4-compatible
(`::/96`), IPv4-translated (`::ffff:0:0/96`) and site-local (`fec0::/10`)
@@ -153,8 +158,10 @@
own veth, its LAN included. The IPv4 address inside an
IPv4-mapped, NAT64 or 6to4 address is checked too, every address a name
resolves to is checked, and the address checked is the one dialled. When the machine cannot tell,
it refuses. A coder run reaches a LAN host only when you named it in
`coders set --allow-lan`; the anchored egress lane has no such exception.
it refuses. A coder run reaches a host you named in `coders set
--allow-lan`, and skips every one of these refusals for it, so a name or
address of this machine you list there is reached too; the anchored egress
lane has no such exception.
The anchored lane's refusal of this machine's own non-private addresses is
new in 0.50.0; before it, that lane refused only the internal ranges. On a
local (unanchored) machine the egress lane refuses this machine too:
@@ -236,6 +243,14 @@
backup files in its own folder or a folder you shared with it.
- **No delete op on shared stores.** The one write lane is a move plan you
approved, and it archives rather than deletes.
- **A shared folder serves only what its grant leaves in.** A host's list,
stat, read and move of a shared folder are each held to the grant's
excludes, by the name the path opens and wherever a link in it leads. A
pattern of one name matches it at any depth; a pattern with a `/`, such as
`.config/gcloud`, matches that run of folders at any depth. The local
search agent's folder, `.agent`, holds the plaintext of every file the
agent read, and no path into one is served or moved, whatever the grant
excludes; the bastion still writes its own report of a move plan there.
- **Verified releases.** The self-updater checks the release manifest's
signature against the release keys compiled into it, then checks the
binary's SHA-256 against that manifest before anything is swapped. The
@@ -254,8 +269,9 @@
## How each claim is tested
[claims.md](claims.md) names, for each point above, the test that fails if
it stops being true, and says where there is none.
[claims.md](claims.md) covers each point above and some further claims not
listed here. For each it names the test that fails if the claim stops being
true, and says where there is none.
To report a security issue, please do not open a public issue. See
[SECURITY.md](../SECURITY.md).