Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,14 @@ omac creates this file the first time you run `omac start`. Key fields:
| `network.mode` | `string` | `"filtered"` | `filtered` (prompt for unknown hosts), `blocked` (no outbound at all), `open` (unrestricted) |
| `environment.allow_vars` | `string[]` | see created file | Env vars passed into the sandbox; everything else is stripped |
| `filesystem.protected_paths` | `string[]` | `["~/.ssh", "~/.gnupg", ...]` | Paths that remain blocked even if a broader grant would cover them |
| `filesystem.registry_config` | `string[]` | `[]` | Ecosystems whose package-registry settings are copied into the sandbox without their credentials. Currently `"npm"`. See [Private package registries](#private-package-registries) |

See [Security model → Sandbox access reference](./security.md#sandbox-access-reference) for the full list of what the agent can and cannot access.

omac never rewrites this file once it exists, so upgrading omac does not add newer default grants to a profile you already have. To pick up the newer defaults, make a copy of your current file, delete the original, and run `omac start` to write a fresh one. Then copy any changes you had made back from your saved copy into the new file.

The reverse also applies. An unknown field is an error, so that a typo cannot quietly weaken the sandbox. A file using a newer field, such as `filesystem.registry_config`, is therefore rejected by an older omac. If you share this file between machines, upgrade omac on all of them before adding a new field.

### Opening a port

To let the agent reach a local service, add the port to `network.open_port` in the sandbox grants file (`~/.config/omac/sandbox-profiles/default.json`):
Expand Down Expand Up @@ -121,6 +124,24 @@ Java (Maven/Gradle) and Node/npm do not reliably route their package downloads t

Node injection requires Node ≥ 22.21.0 (22.x line) or ≥ 24.5.0; on older versions it is skipped and downloads may still fail.

### Private package registries

If your company hosts its own npm packages, `~/.npmrc` says where to find them. A line like `@acme:registry=https://npm.acme.test` means "packages starting with `@acme/` come from that server".

The sandbox blocks `~/.npmrc`, because the same file usually holds an access token. Without it, npm looks for `@acme/` packages on the public registry instead, does not find them, and reports a 404. The error looks like the package does not exist, so this is easy to misread. Allowing the registry's host does not help, because npm never asks it.

To fix this, add `npm` to `filesystem.registry_config`:

```json
{ "filesystem": { "registry_config": ["npm"] } }
```

omac then writes a copy of `~/.npmrc` that contains only the registry addresses, lets the sandbox read that copy, and points npm at it. The real file stays blocked, so no token is copied. If a line cannot be copied without also copying a secret, omac skips that line and tells you which one, both at startup and in `omac doctor`.

Private registries usually also need their host added to `network.allow_domain`, or allowed once at the network prompt.

omac cannot pass on your access token, so packages that require login still fail to install. Only the address is shared, never the credential.

## Audit trail

omac logs every security-relevant action to an append-only file: process launches, network decisions, secret injections. The file is outside the sandbox so the agent cannot tamper with it.
Expand Down
1 change: 1 addition & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ cannot access.
| `/tmp`, `$TMPDIR` | read + write | Temporary files during the agent's work |
| Facade socket (`$TMPDIR/omac-<hash>/bridge.sock`) | connect | The socket the agent uses to reach skill sidecars; created by the facade, not the agent |
| `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.kube`, … | **blocked** | Sensitive credentials |
| `~/.npmrc` | **blocked**; registry addresses can be shared as a stripped copy | Usually holds an access token. See [Private package registries](./configuration.md#private-package-registries) |
| `~/.config/omac` (approval store, sandbox profiles, global registry) | **not mounted** | The agent must not be able to forge skill approvals |
| `.env` / `.envrc` files (including nested ones inside the project) | **blocked** | Often contain secrets |
| `~/.cache`, `~/Library/Caches` (host cache roots) | **blocked** | Prevents cross-project cache poisoning; omac provides its own isolated cache |
Expand Down
95 changes: 95 additions & 0 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"slices"
"strings"

"github.com/tngtech/oh-my-agentic-coder/internal/builtinskills"
Expand All @@ -15,6 +16,7 @@ import (
"github.com/tngtech/oh-my-agentic-coder/internal/osinfo"
"github.com/tngtech/oh-my-agentic-coder/internal/profileaudit"
"github.com/tngtech/oh-my-agentic-coder/internal/registry"
"github.com/tngtech/oh-my-agentic-coder/internal/registryconf"
"github.com/tngtech/oh-my-agentic-coder/internal/sandboxprofile"
"github.com/tngtech/oh-my-agentic-coder/internal/sandboxrun"
"github.com/tngtech/oh-my-agentic-coder/internal/skillconfig"
Expand Down Expand Up @@ -224,6 +226,11 @@ func runDoctor(args []string, env *Env) int {
// that policy linted rather than an unused "default".
doctorProfileLint(env, defaultPolicyRef(lc))

// Advisory: a private-registry mapping the sandbox cannot see makes
// scoped installs 404 with no denial anywhere to point at, so nothing
// else in doctor or diagnose would mention it.
doctorRegistryConfig(env, defaultPolicyRef(lc))

fmt.Fprintln(env.Stdout, "\nWhen a run fails, `omac diagnose` shows what the sandbox blocked and why.")

if failures > 0 {
Expand Down Expand Up @@ -252,6 +259,94 @@ func doctorProfileLint(env *Env, profileRef string) {
}
}

// doctorRegistryConfig reports whether ~/.npmrc maps a scope to a private
// registry that the sandbox cannot see. That combination fails in a way no
// other check catches: the masked file yields no denial event, and npm's
// fallback to the public registry returns a plain 404 that reads like "no
// such package" (see #150, #241).
//
// Advisory only — it never affects doctor's exit code.
func doctorRegistryConfig(env *Env, profileRef string) {
profile, _, err := sandboxprofile.Resolve(profileRef)
if err != nil {
return // profile problems are already reported by the sandbox section
}
enabled := slices.Contains(profile.Filesystem.RegistryConfig, sandboxprofile.RegistryConfigNPM)
src, err := registryconf.NPMUserConfig()
if err != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[NICE TO HAVE] bug, consistency

The launch path turns a non-ENOENT read failure on ~/.npmrc (e.g. EACCES after a root-owned sudo npm config set) into a projection warning (registryconf.go:143-145), but doctor swallows the identical error and prints nothing. Report it as [warn] or reuse the projector's Warning path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: reported as [warn] with the consequence spelled out, matching the
launch path. Worth flagging that doctor is the only check that runs before a
launch, so this silence was the more costly of the two.
TestDoctorRegistryConfigReportsUnreadableConfig.

return
}
overridden := sandboxprofile.BuildOverrideLookup(profile.Filesystem.OverrideDeny)[src]

notice, err := registryconf.InspectNPM(enabled, overridden)
if err != nil {
// The launch path turns the same failure into a projection warning
// (registryconf.projectNPM), so staying silent here would mean the
// only place that can warn *before* a run does not.
fmt.Fprintf(env.Stdout, "[warn] registry config: cannot inspect %s: %v\n", src, err)
fmt.Fprintf(env.Stdout, " A private-registry mapping in that file cannot be projected, so scoped\n")
fmt.Fprintf(env.Stdout, " installs may fail with a 404 against the public registry.\n")
return
}
if notice == nil {
return
}
hosts := strings.Join(notice.Hosts, ", ")
// Each condition is reported on its own: a profile can have BOTH
// registry_config and override_deny, and reporting only the former
// ("[ok] … projected") would reassure the user while the real
// token-bearing file stays readable by the sandbox.
switch {
case len(notice.Hosts) == 0:
// Only rejections to report; the mapping list is empty.
case notice.Enabled:
fmt.Fprintf(env.Stdout, "[ok] registry config: %s mappings (%s) are projected into the sandbox\n",
notice.Ecosystem, hosts)
default:
fmt.Fprintf(env.Stdout, "[warn] registry config: %s maps a scope to %s, but the sandbox cannot read it\n",
notice.Source, hosts)
fmt.Fprintf(env.Stdout, " Scoped installs will fail with a 404 against the public registry. Fix:\n")
fmt.Fprintf(env.Stdout, " add filesystem.registry_config: [%q] to the sandbox profile%s.\n",
notice.Ecosystem, credentialNote(notice.Credentialed))
}

if notice.Overridden {
fmt.Fprintf(env.Stdout, "[warn] registry config: %s is exposed to the sandbox via filesystem.override_deny\n",
notice.Source)
fmt.Fprintf(env.Stdout, " That grants the whole file%s.\n", credentialSuffix(notice.Credentialed))
if notice.Enabled {
fmt.Fprintf(env.Stdout, " filesystem.registry_config is already projecting the mappings, so this grant\n")
fmt.Fprintf(env.Stdout, " is redundant — drop it to keep the credential protected.\n")
} else {
fmt.Fprintf(env.Stdout, " Prefer filesystem.registry_config: [%q], which projects only the registry\n", notice.Ecosystem)
fmt.Fprintf(env.Stdout, " mappings and drops every credential.\n")
}
}

// Rejections are the silent-failure case: config exists, omac will not
// use it, and nothing else would say so.
for _, r := range notice.Rejected {
fmt.Fprintf(env.Stdout, "[warn] registry config: %s cannot be projected from %s\n", r.Key, notice.Source)
fmt.Fprintf(env.Stdout, " %s\n", r.Reason)
}
}

// credentialSuffix describes what an override_deny grant exposes.
func credentialSuffix(credentialed bool) string {
if credentialed {
return ", including the auth token it holds"
}
return ""
}

// credentialNote explains why the projection beats the blunt alternative.
func credentialNote(credentialed bool) string {
if credentialed {
return " (the file also holds an auth token, so override_deny would expose it)"
}
return ""
}

// doctorBuiltinSkills reports whether omac's built-in skills (provisioned by
// `omac setup`) are present and current in each installed harness's native
// skills dir. It is advisory: a missing/stale/foreign bundle is a warning, not
Expand Down
Loading
Loading