bastion 0.58.1
This commit is contained in:
+41
-5
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
All notable changes to the bastion.
|
All notable changes to the bastion.
|
||||||
|
|
||||||
The version in `deploy/VERSION` on `main` is the next release, not necessarily
|
The published version is whatever <https://privatae.ai/bastion/version.json>
|
||||||
the one machines run. A merged change waits there until it is published. The
|
names at the moment, and it is the only version a bastion will update to. This
|
||||||
published version is whatever <https://privatae.ai/bastion/version.json> names
|
public repository receives each release after it is published, so its
|
||||||
at the moment, and it is the only version a bastion will update to. Between a
|
`deploy/VERSION` can trail that manifest.
|
||||||
merge and a publish, `main` is ahead of the manifest.
|
|
||||||
|
|
||||||
Two things can keep a version listed here from reaching most machines:
|
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
|
that is settled. The newest entries may not be published yet; the manifest
|
||||||
says which version is. Older entries are not marked.
|
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
|
## 0.58.0
|
||||||
|
|
||||||
- **A Host answers four common questions about a machine itself, instead of
|
- **A Host answers four common questions about a machine itself, instead of
|
||||||
|
|||||||
+4
-4
@@ -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
|
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.
|
sets `PRIVATAE_REQUIRE_AGE`, so there they fail rather than skip.
|
||||||
|
|
||||||
The test suite covers all three platforms' parsing and judgement logic from
|
Most parsing and judgement logic is in files with no build tag and runs under
|
||||||
one `go test` on any OS. Platform-specific decisions live in files with no
|
one `go test` on any OS. Some platform parsers (on Linux and macOS) and the
|
||||||
build tag; only the enumerators are tagged. Keep it that way: a new judgement
|
Linux sandbox are in tagged files, and their tests run only on that OS. A new
|
||||||
goes in an untagged file with a table test. Check it with
|
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`
|
`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
|
does not compile test files, so only vet sees a test that uses a Linux-only
|
||||||
name.
|
name.
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ gates. Your files stay where they are. Nothing on this machine drives the host:
|
|||||||
there is no MCP endpoint here.
|
there is no MCP endpoint here.
|
||||||
|
|
||||||
The published release is whatever `privatae.ai/bastion/version.json` names.
|
The published release is whatever `privatae.ai/bastion/version.json` names.
|
||||||
`deploy/VERSION` on `main` is the next release, which may not be published
|
This repository receives each release after it is published, so
|
||||||
yet. From 0.42.0 on, every release manifest is signed and every published
|
`deploy/VERSION` here can trail it. From 0.42.0 on, every release manifest is
|
||||||
binary can be rebuilt from this source and compared
|
signed and every published binary can be rebuilt from this source and compared
|
||||||
([Verify a release](#verify-a-release)). See [CHANGELOG.md](CHANGELOG.md).
|
([Verify a release](#verify-a-release)). See [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
```sh
|
```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
|
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**.
|
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
|
The bastion dials your host; your host never dials your machine. The only
|
||||||
opened on any externally reachable interface (the control ports and the local
|
ports it holds open on an outside interface are its tunnels' WireGuard UDP
|
||||||
console bind loopback only; the optional memory-mirror forwarder also binds
|
ports, one per host it reaches through a tunnel, each at a port the system
|
||||||
the replica container's private gateway address). The only thing your host can reach is the
|
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
|
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
|
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,
|
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.
|
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;
|
- **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,
|
- **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
|
one port per service-slot server and one per coder run, each checked when a
|
||||||
connection is accepted. ([tunnel](docs/lanes.md#tunnel))
|
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))
|
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
|
- **A server in the service slot has no network.** The host's tunnel is its
|
||||||
only way in. ([service slot](docs/service-slot.md))
|
only way in. ([service slot](docs/service-slot.md))
|
||||||
- **Egress for someone else reaches the public internet, never this
|
- **Egress for someone else reaches the public internet, and nothing on this
|
||||||
machine**: a coder run's proxy and an anchored machine's egress lane refuse
|
machine or your network that you did not name**: a coder run's proxy and an
|
||||||
internal addresses and this machine's own (a coder run reaches a LAN host
|
anchored machine's egress lane refuse internal addresses and this machine's
|
||||||
you named). A local machine's egress lane refuses this machine and your
|
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
|
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))
|
not covered. ([full text](docs/security-model.md))
|
||||||
- **A terminal is checked here and logged.** On a local machine it opens when
|
- **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))
|
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
|
- **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))
|
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
|
- **Verified releases.** The self-updater checks the manifest's signature and
|
||||||
the binary's SHA-256 before swapping; the release key never reaches the
|
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)
|
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
|
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
|
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
|
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
|
as one commit. In both, the release is tagged `v<version>` (0.55.0 to 0.55.2
|
||||||
tree is identical to the commit the signed release manifest names as
|
were not) on a commit whose tree is identical to the commit the signed release
|
||||||
`built_from`. Every binary can be rebuilt from the tag and compared with the
|
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).
|
published one: see [Verify a release](#verify-a-release).
|
||||||
|
|
||||||
Issues and pull requests are welcome on GitHub, at
|
Issues and pull requests are welcome on GitHub, at
|
||||||
@@ -359,7 +367,11 @@ CGO_ENABLED=0 go build -o privatae-bastion .
|
|||||||
./privatae-bastion version
|
./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
|
To build a specific release rather than the latest source, check out its tag
|
||||||
first (`git checkout v<version>`).
|
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
|
`client-go/release_sign.go`, checks out the release's source, rebuilds it, and
|
||||||
compares every binary's sha256 with the published one.
|
compares every binary's sha256 with the published one.
|
||||||
|
|
||||||
Each published release in this repository carries the tag `v<version>` (for
|
From 0.53.2 on, each release in this repository carries the tag `v<version>`
|
||||||
example `v0.54.1`). The manifest's `built_from` names the
|
(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,
|
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
|
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
|
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
|
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.
|
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
|
Only a release tagged here can be checked this way from a clone of this
|
||||||
repository; the tags of earlier releases are not in it.
|
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
|
Each release on GitHub carries the six binaries, the signed manifest
|
||||||
`version.json`, its signature `version.json.<first 16 hex of its sha256>.sig`
|
`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,
|
`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
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
+8
-6
@@ -102,13 +102,15 @@ one installed.
|
|||||||
**Checking our work.** Release builds are reproducible.
|
**Checking our work.** Release builds are reproducible.
|
||||||
`deploy/verify-release.sh` fetches the published manifest, checks its
|
`deploy/verify-release.sh` fetches the published manifest, checks its
|
||||||
signature, checks out the release's source, rebuilds it, and compares every
|
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
|
binary's digest. From 0.53.2 on, apart from 0.55.0 to 0.55.2, each release in
|
||||||
carries the annotated tag `v<version>`, whose message records the release's
|
this repository carries the annotated tag `v<version>`, whose message records
|
||||||
`built_from`. That field names the commit in the repository the release was
|
the release's `built_from`. A release arrives here after it is published.
|
||||||
built in, which is not a commit here, so the script uses the tag and prints
|
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
|
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
|
serve. Only a release tagged here can be checked this way, and releases
|
||||||
earlier) cannot be checked this way.
|
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 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
|
the folder you granted. The agent is a separate program in its own repository
|
||||||
|
|||||||
+2
-2
@@ -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
|
// 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
|
// pairing claims: the per-host git proxy, through which local git reaches
|
||||||
// the bastion → host → platform path). Allocated lazily for pairings that predate it.
|
// git.privatae.ai via that host (gitlane.go). Allocated lazily for pairings that predate it.
|
||||||
func nextGitProxyAddr(cfgs []*Config) string {
|
func nextGitProxyAddr(cfgs []*Config) string {
|
||||||
used := map[string]bool{}
|
used := map[string]bool{}
|
||||||
for _, c := range cfgs {
|
for _, c := range cfgs {
|
||||||
|
|||||||
+83
-10
@@ -276,27 +276,82 @@ func dcnResolvePath(root, rpath string) (string, bool) {
|
|||||||
return joined, true
|
return joined, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// dcnExcluded reports whether rpath trips any exclude pattern. Patterns match the
|
// dcnExcluded reports whether rpath trips any exclude pattern, or is inside a
|
||||||
// basename and every path segment (glob per segment: ".ssh", "*.key", ".env*"),
|
// local search agent's folder (dcnInAgentDirOn), which no grant reaches. A
|
||||||
// compared the way this platform's file system compares names (dcnNameKey).
|
// 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.
|
// rpath is a real relative path; a path a host sent goes through dcnExcludedAsSent.
|
||||||
func dcnExcluded(excludes []string, rpath string) bool {
|
func dcnExcluded(excludes []string, rpath string) bool {
|
||||||
return dcnExcludedOn(runtime.GOOS, excludes, rpath)
|
return dcnExcludedOn(runtime.GOOS, excludes, rpath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func dcnExcludedOn(goos string, excludes []string, rpath string) bool {
|
func dcnExcludedOn(goos string, excludes []string, rpath string) bool {
|
||||||
|
if dcnInAgentDirOn(goos, rpath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
segs := strings.Split(filepath.ToSlash(rpath), "/")
|
segs := strings.Split(filepath.ToSlash(rpath), "/")
|
||||||
|
for i, seg := range segs {
|
||||||
|
segs[i] = dcnNameKey(goos, seg)
|
||||||
|
}
|
||||||
for _, pat := range excludes {
|
for _, pat := range excludes {
|
||||||
pat = strings.TrimSpace(pat)
|
pat = strings.TrimSpace(pat)
|
||||||
if pat == "" {
|
if pat == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pat = dcnNameKey(goos, pat)
|
var run []string
|
||||||
for _, seg := range segs {
|
for _, p := range strings.Split(pat, "/") {
|
||||||
if ok, _ := filepath.Match(pat, dcnNameKey(goos, seg)); ok {
|
if p != "" {
|
||||||
return true
|
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
|
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).
|
// A path cleanRel refuses is judged as sent (the sandbox refuses it anyway).
|
||||||
func dcnExcludedAsSent(excludes []string, root, rpath string) bool {
|
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)
|
joined, ok := cleanRel(root, rpath)
|
||||||
if !ok {
|
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
|
return true
|
||||||
}
|
}
|
||||||
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
|
resolvedRoot, err := filepath.EvalSymlinks(filepath.Clean(root))
|
||||||
@@ -343,7 +412,7 @@ func dcnExcludedAsSent(excludes []string, root, rpath string) bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return dcnExcluded(excludes, rel)
|
return judge(rel)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dcnWhereItLeads is p with its deepest existing part's links resolved and the
|
// 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")
|
dcnRefuse(ws, dir, op, storeID, rpath, "op '"+mask+"' not in the store's granted mask")
|
||||||
return nil
|
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) {
|
if rpath != "" && dcnExcludedAsSent(terms.Excludes, terms.Root, rpath) {
|
||||||
dcnRefuse(ws, dir, op, storeID, rpath, "path is excluded by the grant")
|
dcnRefuse(ws, dir, op, storeID, rpath, "path is excluded by the grant")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ func dcnCheckPlan(root string, excludes []string, moves []dcnMove) []dcnPlanProb
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seenTo[pathKey(dst)], seenFrom[pathKey(src)] = i, i
|
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) {
|
if dcnExcludedAsSent(excludes, root, m.From) {
|
||||||
bad(i, m, "source is excluded by the grant")
|
bad(i, m, "source is excluded by the grant")
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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
@@ -68,17 +68,22 @@ type freeagentConfig struct {
|
|||||||
|
|
||||||
// faManagedSanitizeMode is the sanitize mode the bastion writes into every
|
// faManagedSanitizeMode is the sanitize mode the bastion writes into every
|
||||||
// manifest it deploys (faManifestText). Flag mode is the agent's rules pass
|
// manifest it deploys (faManifestText). Flag mode is the agent's rules pass
|
||||||
// only: it replaces e-mail addresses, phone numbers, card, bank and ID
|
// only (the agent's pii/detect.py): it replaces what a pattern can find
|
||||||
// numbers, IP and MAC addresses and keys, and does not find names, street
|
// (faSanitizeNotes["flag"] names each kind), and does not look for names,
|
||||||
// addresses or dates of birth.
|
// street addresses, dates of birth, ID numbers such as passport numbers, or
|
||||||
|
// bank account numbers other than IBANs.
|
||||||
const faManagedSanitizeMode = "flag"
|
const faManagedSanitizeMode = "flag"
|
||||||
|
|
||||||
// faSanitizeNotes says, for each mode a reply can carry, what personal data
|
// 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
|
// 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.
|
// here is reported as not known to remove personal data.
|
||||||
var faSanitizeNotes = map[string]string{
|
var faSanitizeNotes = map[string]string{
|
||||||
"flag": "rules only: e-mail addresses, phone numbers, card, bank and ID numbers, IP and MAC addresses " +
|
"flag": "rules only. Replaced: e-mail addresses, phone numbers, card numbers, US social security numbers, " +
|
||||||
"and keys were replaced; names, street addresses and dates of birth were not",
|
"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": "",
|
"sterilize": "",
|
||||||
"none": "the agent's manifest has no sanitize stage, so nothing in the text was replaced",
|
"none": "the agent's manifest has no sanitize stage, so nothing in the text was replaced",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,8 +330,16 @@ func TestASanitizeStampNamesTheModeThatRan(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(faSanitizeNotes["flag"], "names") {
|
// The agent's rules pass (pii/detect.py) does not look for these, so the
|
||||||
t.Fatal("the flag note must say names are left in")
|
// 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"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,13 +137,14 @@ func landsInside(root, p string) bool {
|
|||||||
return false
|
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
|
// 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.
|
// link is followed, and on the text of the path the sandbox opens.
|
||||||
func excludedOnDisk(root, p string) bool {
|
func excludedOnDisk(root, p string) bool {
|
||||||
check := func(rel string) bool {
|
check := func(rel string) bool {
|
||||||
for _, seg := range strings.Split(rel, "/") {
|
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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-8
@@ -2,12 +2,13 @@ package main
|
|||||||
|
|
||||||
// Git lane: Gitea through the Host.
|
// Git lane: Gitea through the Host.
|
||||||
//
|
//
|
||||||
// git.privatae.ai answers only platform/DMZ addresses now, so the user's LOCAL git
|
// This bastion offers a loopback HTTP proxy for the user's LOCAL git to reach
|
||||||
// cannot reach it. This bastion offers a loopback HTTP proxy for git; every local
|
// git.privatae.ai through their host; every local connection is spliced over the
|
||||||
// connection is spliced over the tunnel into ONE WebSocket on the host (/bastion-git,
|
// tunnel into ONE WebSocket on the host (/bastion-git, same bearer + mTLS binding
|
||||||
// same bearer + mTLS binding as the other lanes) and the host relays it out the
|
// as the other lanes) and the host relays it on to the forge. Which addresses the
|
||||||
// platform path to git.privatae.ai ONLY. The wire framing is the egress lane's
|
// forge itself answers is the forge's rule; nothing here sets or checks it. The
|
||||||
// ('D'+bytes / 'E' end) with the roles swapped, so wsConn is reused unchanged.
|
// 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`):
|
// 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>
|
// 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")
|
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 {
|
func cmdGitSetup() error {
|
||||||
all, err := loadPairings()
|
all, err := loadPairings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -208,7 +210,7 @@ func cmdGitSetup() error {
|
|||||||
if len(all) == 0 {
|
if len(all) == 0 {
|
||||||
return fmt.Errorf("no pairings — run `privatae-bastion pair <token>` first")
|
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 {
|
if len(all) > 1 {
|
||||||
fmt.Println("(several hosts are paired: pick ONE for git, and name it with --host in the helper line)")
|
fmt.Println("(several hosts are paired: pick ONE for git, and name it with --host in the helper line)")
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -142,8 +142,8 @@ Usage:
|
|||||||
let your hosts run sealed coding runs on this machine (Linux, as
|
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
|
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
|
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
|
privatae-bastion git-setup print the git config lines that route git.privatae.ai
|
||||||
through this bastion (the forge is reachable only this way)
|
through this bastion and your host
|
||||||
privatae-bastion git-credential git's credential helper — fetches your forge token from your
|
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
|
host on every git auth (git-setup resets the helper chain so no
|
||||||
other helper caches it)
|
other helper caches it)
|
||||||
|
|||||||
+3
-3
@@ -533,9 +533,9 @@ func runPairing(ctx context.Context, cfg *Config) error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// 2d. Git lane: loopback HTTP proxy for git.privatae.ai only.
|
// 2d. Git lane: loopback HTTP proxy for git.privatae.ai only.
|
||||||
// Each local connection = one WebSocket to the host, relayed out the PLATFORM
|
// Each local connection = one WebSocket to the host, which relays it on to
|
||||||
// path there (the forge answers platform/DMZ addresses only). No credential
|
// the forge (gitlane.go). No credential rides this lane; git presents its
|
||||||
// rides this lane; git presents its own via the git-credential helper below.
|
// own via the git-credential helper below.
|
||||||
if cfg.GitProxyAddr != "" {
|
if cfg.GitProxyAddr != "" {
|
||||||
if gitLn, err := net.Listen("tcp", cfg.GitProxyAddr); err != nil {
|
if gitLn, err := net.Listen("tcp", cfg.GitProxyAddr); err != nil {
|
||||||
// Optional lane: a port collision must not take the tunnel, lanes and
|
// Optional lane: a port collision must not take the tunnel, lanes and
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.58.0
|
0.58.1
|
||||||
|
|||||||
+11
-9
@@ -121,13 +121,15 @@ The file:
|
|||||||
needs. It cannot write to that folder. `backup unshare` takes it back, and
|
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.
|
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
|
This bastion does not bring up the new host. A Host on hardware you own is a
|
||||||
needs infrastructure that is not part of this program, and not built yet:
|
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
|
1. Copy the backup and its key file (`<stamp>.tar.zst.age` and
|
||||||
policy, admission and storage the host's pod expects;
|
`<stamp>.key.age`) out of the lost host's backup folder here.
|
||||||
- the operator that creates and runs a host's pod on that cluster;
|
2. In the C-Series Host's Backups app, under "Restore from a file", choose
|
||||||
- the host's container images, from a registry that cluster can pull from;
|
the two together and give the lost host's recovery key. The page opens the
|
||||||
- the certificate authority that issues the host its identity, and the
|
key file with it and seals the backup's key to the new Host; the recovery
|
||||||
services a host reaches when it starts, or replacements for them;
|
key never leaves the page.
|
||||||
- a place the new host can fetch the backup from, as above.
|
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
@@ -14,9 +14,9 @@ Run the tests:
|
|||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
The suite covers all three platforms' parsing and judgement logic from a single
|
Most parsing and judgement logic is in files with no build tag and runs under
|
||||||
`go test` on any OS, because every platform-specific decision is kept in files
|
`go test` on any OS. Some platform parsers and the Linux sandbox are in tagged
|
||||||
with no build tag and only the enumerators are tagged.
|
files, and their tests run only on that OS.
|
||||||
|
|
||||||
The walls a host's input has to get through also have fuzz targets
|
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
|
(`client-go/fuzz_*_test.go`): the path sandbox for store reads and move
|
||||||
|
|||||||
+18
-12
@@ -1,6 +1,7 @@
|
|||||||
# How each claim is tested
|
# 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
|
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
|
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. |
|
| | `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`: `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_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`). |
|
| | 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. |
|
| 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. |
|
| | `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. |
|
| 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. |
|
| | `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. |
|
| 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. |
|
| | `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. |
|
| | `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. |
|
| | `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`. |
|
| | `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_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`: `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. |
|
| 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`: `TestAnApprovalIsSingleUse` | A spent approval does not fund a second model download. |
|
||||||
| | `modelswap_test.go`: `TestUnenrolledMachineCannotHaveAModelPulled` | With no factor enrolled, a model pull is refused. |
|
| | `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. |
|
| | `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. |
|
| | `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. |
|
| | 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`: `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`: `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. |
|
| | `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. |
|
| | `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`: `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. |
|
| | `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_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_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`: `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. |
|
| | `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. |
|
| 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`: `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`: `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`: `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`: `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. |
|
| | `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`: `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`: `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_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`: `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`: `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`: `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`: `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. |
|
| | `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. |
|
| 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`: `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`: `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. |
|
| | `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. |
|
| | `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. |
|
| | 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`: `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. |
|
| | `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`. |
|
| 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. |
|
| 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`: `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. |
|
| | `release_sign_test.go`: `TestAnUnsignedManifestIsNotRead` | A manifest with no signature reaches neither the updater nor the local page. |
|
||||||
|
|||||||
+8
-2
@@ -28,16 +28,22 @@
|
|||||||
| `ops [--host <host>]` | The ops your host wrote that this machine holds, and the standing op grants. |
|
| `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. |
|
| `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 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 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. |
|
| `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. |
|
| `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-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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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` | 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 [--force]` | Record what is normal on this machine. Administrator only. |
|
||||||
| `observe:baseline:show` | Print the baseline this machine holds. |
|
| `observe:baseline:show` | Print the baseline this machine holds. |
|
||||||
|
|||||||
+5
-4
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
`privatae-bastion ui` opens a page served by the running daemon on `127.0.0.1`
|
`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
|
and reachable from nowhere else: pairings, the anchor, containment state, marks
|
||||||
on automated programs, the dry-run policy, and pending approvals. On Windows
|
on automated programs, the dry-run policy, pending approvals, set-up, what this
|
||||||
a tray icon shows a one-line state and offers four actions: open what is
|
machine has given away (where you switch a host's capabilities off), and a
|
||||||
waiting for your approval, open the console, open your context page, and stop
|
disk map. On Windows a tray icon shows a one-line state. Run as you, it opens
|
||||||
the bastion.
|
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
@@ -13,7 +13,11 @@ your machine, never on the platform.
|
|||||||
Signature-verified store grants for folders you share. The host's verify key is
|
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.
|
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
|
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
|
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.
|
never deletes them. Dropping a grant removes the grant, not your files.
|
||||||
**No delete op exists.**
|
**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
|
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
|
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
|
`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
|
`flag` mode, which replaces only what a pattern can find: e-mail addresses,
|
||||||
numbers, IP and MAC addresses and keys, and does **not** find names, street
|
phone numbers, card numbers, US social security numbers, IBANs, US bank
|
||||||
addresses or dates of birth. So a Host reading your folder through search
|
routing numbers written after a word like "routing", Bitcoin addresses that
|
||||||
sees those.
|
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.
|
||||||
|
|||||||
@@ -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
|
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
|
no directory with it. A C-Series Host
|
||||||
([Privatae/cseries](https://github.com/Privatae/cseries)) is one: it runs in
|
([Privatae/cseries](https://github.com/Privatae/cseries)) is one: it runs in
|
||||||
its own Linux virtual machine. Its installer, `npx cseries`, forwards two ports
|
its own Linux virtual machine. Its installer, `npx cseries`, forwards three
|
||||||
of the VM to this machine's loopback, installs this bastion as the machine's
|
ports of the VM (8800, 8810, 8811) to this machine's loopback, installs this
|
||||||
system service, and on every pairing runs these in one elevated step (on
|
bastion as the machine's system service, and on every pairing runs these in
|
||||||
Windows, from an Administrator PowerShell instead of `sudo -H`):
|
one elevated step (on Windows, from an Administrator PowerShell instead of
|
||||||
|
`sudo -H`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 1. The service's state, networked.
|
# 1. The service's state, networked.
|
||||||
|
|||||||
+15
-2
@@ -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
|
On macOS and Windows, `privatae-bastion service install|uninstall|status` does
|
||||||
the same by hand. An administrator's commands (`sudo -H …` on macOS, an
|
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
|
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,
|
that does not administer the machine, `pair`, `run`, `status` and `switch` are
|
||||||
so that account does not end up with a second, empty bastion of its own.
|
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
|
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`
|
(`version.json`, read by the installers at `https://dl.privatae.ai/version.json`
|
||||||
|
|||||||
+13
-12
@@ -3,27 +3,28 @@
|
|||||||
## Tunnel
|
## Tunnel
|
||||||
A userspace WireGuard tunnel (gVisor netstack) scoped so this machine can reach
|
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.
|
**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
|
The tunnel is dialled from this machine. Its WireGuard UDP port, on every
|
||||||
exactly three addresses, all under the `inference` capability family: a
|
address at a port the system picks when the tunnel comes up, drops every
|
||||||
forward to a local ollama (port 11434), a forward to an OpenAI-compatible
|
packet that does not come from the host's key. Inside the tunnel, the
|
||||||
server when one is configured (port 18080), and a forward to a local PII
|
bastion accepts only on gated forwards: ollama (11434), an OpenAI-compatible server (18080) and a PII
|
||||||
sterilizer when one is configured. Nothing else listens.
|
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
|
## Git lane
|
||||||
A loopback HTTP proxy plus a `git-credential` helper, so your local `git` reaches
|
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,
|
`git.privatae.ai` through your host. `privatae-bastion git-setup` prints the
|
||||||
so this lane is the way in. `privatae-bastion git-setup` prints the three
|
three config lines: the proxy, a reset of the credential-helper chain for that URL
|
||||||
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.
|
(so no other helper caches your token on this machine), and the helper itself.
|
||||||
|
|
||||||
## Worker lanes
|
## Worker lanes
|
||||||
Outbound-only reverse relays to the host: egress, management, harness, and
|
Outbound-only reverse relays to the host: egress, management, harness, and
|
||||||
session. The management lane runs a **fixed-argv allowlist**. Ops that touch
|
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,
|
your files are dedicated Go handlers with no shell. Two of them start a
|
||||||
the one that runs a program is text extraction: it invokes a local tool named
|
program: text extraction invokes a local tool named in a fixed table
|
||||||
in a fixed table
|
|
||||||
(`pdftotext`, `ffprobe`, and the like) from a fixed argv template, and the
|
(`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
|
## Session lane
|
||||||
An interactive terminal that runs on this machine and streams to the host over
|
An interactive terminal that runs on this machine and streams to the host over
|
||||||
|
|||||||
@@ -38,9 +38,14 @@ deploy/
|
|||||||
release-sign/ the signing tool: runs where the release key is, never here
|
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
|
release-verify.sh check a manifest's signature against the pinned keys
|
||||||
verify-release.sh rebuild a published release and compare its digests
|
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
|
fuzz.sh run every fuzz target for a bounded time
|
||||||
check-claims.sh check that every test the security table cites exists
|
check-claims.sh check that every test the security table cites exists
|
||||||
docs/ reference pages, listed in docs/README.md
|
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
|
LICENSE GNU GPL v3
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+26
-10
@@ -1,10 +1,15 @@
|
|||||||
# Security model
|
# Security model
|
||||||
|
|
||||||
- **The bastion dials out.** Every lane is a reverse connection from this
|
- **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;
|
machine to the host. The only ports open on an outside interface are the
|
||||||
the control ports and console bind loopback only, and the optional
|
tunnels' WireGuard UDP ports: one for each host paired over a tunnel, on
|
||||||
memory-mirror forwarder binds loopback plus the replica container's private
|
every IPv4 and IPv6 address, at a port the system picks when that tunnel
|
||||||
gateway address, with every accept source-checked.
|
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
|
- **Inside the tunnel, only gated listeners.** The host can connect to up to
|
||||||
three inference forwards (local ollama, an OpenAI-compatible server, a PII
|
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
|
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
|
- **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
|
only way in, and it cannot reach this machine, your network or the
|
||||||
internet.
|
internet.
|
||||||
- **Egress for someone else reaches the public internet, never this
|
- **Egress for someone else reaches the public internet, and nothing on
|
||||||
machine.** A coder run's proxy, always, and the egress lane on an anchored
|
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),
|
machine refuse loopback, link-local (the cloud metadata address included),
|
||||||
private, CGNAT and reserved addresses, the deprecated IPv4-compatible
|
private, CGNAT and reserved addresses, the deprecated IPv4-compatible
|
||||||
(`::/96`), IPv4-translated (`::ffff:0:0/96`) and site-local (`fec0::/10`)
|
(`::/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
|
own veth, its LAN included. The IPv4 address inside an
|
||||||
IPv4-mapped, NAT64 or 6to4 address is checked too, every address a name
|
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,
|
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
|
it refuses. A coder run reaches a host you named in `coders set
|
||||||
`coders set --allow-lan`; the anchored egress lane has no such exception.
|
--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
|
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
|
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:
|
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.
|
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
|
- **No delete op on shared stores.** The one write lane is a move plan you
|
||||||
approved, and it archives rather than deletes.
|
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
|
- **Verified releases.** The self-updater checks the release manifest's
|
||||||
signature against the release keys compiled into it, then checks the
|
signature against the release keys compiled into it, then checks the
|
||||||
binary's SHA-256 against that manifest before anything is swapped. The
|
binary's SHA-256 against that manifest before anything is swapped. The
|
||||||
@@ -254,8 +269,9 @@
|
|||||||
|
|
||||||
## How each claim is tested
|
## How each claim is tested
|
||||||
|
|
||||||
[claims.md](claims.md) names, for each point above, the test that fails if
|
[claims.md](claims.md) covers each point above and some further claims not
|
||||||
it stops being true, and says where there is none.
|
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
|
To report a security issue, please do not open a public issue. See
|
||||||
[SECURITY.md](../SECURITY.md).
|
[SECURITY.md](../SECURITY.md).
|
||||||
|
|||||||
Reference in New Issue
Block a user