Sign In

@digital-threads/loom

Package Overview
Dependencies
Maintainers
1
Versions
17
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@digital-threads/loom - npm Package Compare versions

Comparing version
0.5.0
to
0.6.0
+29
dist/core/layers/security/egress-allowlist.js
// Egress allowlist matching (Phase 2 of loom-xclx). A host passes if it matches
// an allowlist entry exactly, or matches a `*.domain` wildcard as a subdomain
// (api.anthropic.com vs *.anthropic.com). Matching is case-insensitive and the
// wildcard only matches a real subdomain — it never matches the bare apex, and a
// "github.com.attacker.net" suffix trick can't sneak past it.
/** The hosts a coding agent legitimately reaches: the model API, package
* registries, and git hosts. The operator extends this from Phase 1's observed
* egress; off-list hosts are refused when enforcement is on. */
export const DEFAULT_EGRESS_ALLOW = [
"api.anthropic.com", "*.anthropic.com",
"registry.npmjs.org", "*.npmjs.org",
"github.com", "*.github.com", "*.githubusercontent.com",
"pypi.org", "*.pypi.org", "files.pythonhosted.org",
];
export function allowsHost(host, allowlist) {
const h = host.trim().toLowerCase();
if (!h)
return false;
return allowlist.some((raw) => {
const p = raw.trim().toLowerCase();
if (!p)
return false;
if (p.startsWith("*.")) {
const suffix = p.slice(1); // ".anthropic.com"
return h.length > suffix.length && h.endsWith(suffix); // a real subdomain, not the apex
}
return h === p;
});
}
// Turns the egress proxy's raw onHost callbacks into deduped audit events. One
// `audit.egress.observed` per distinct destination (not per connection), and a
// running set of hosts the host process can persist as the task's egress record
// — the raw material for the Phase 2 allowlist.
import { emitAudit } from "./config.js";
export function createEgressObserver(ids) {
const seen = new Set();
const blocked = new Set();
const emit = (type, severity, arrow, key) => emitAudit(ids.projectId, {
schema: "loom.event.v1",
ts: Date.now(),
source: "loom",
projectId: ids.projectId,
taskId: ids.taskId,
type,
severity,
message: `egress ${arrow} ${key}`,
});
return {
onHost: (host, port) => {
const key = `${host}:${port}`;
if (seen.has(key))
return; // already audited this destination
seen.add(key);
emit("audit.egress.observed", "info", "→", key);
},
onBlock: (host, port) => {
const key = `${host}:${port}`;
if (blocked.has(key))
return; // one warning per refused destination, not per attempt
blocked.add(key);
emit("audit.egress.blocked", "warn", "⛔", key);
},
hosts: () => [...seen],
};
}
// A local forward proxy that OBSERVES the agent's outbound destinations and
// forwards them — it never blocks (Phase 1 of the egress allowlist, loom-xclx).
// For HTTPS the client sends `CONNECT host:port` and we read the host from that
// line WITHOUT decrypting the TLS that follows (no MITM, no certificates); for
// plain HTTP we read the `Host` header. Each destination is reported via onHost,
// then the bytes are piped straight through to the real upstream.
//
// Phase 2 will add an allowlist that refuses off-list CONNECTs and pair this with
// a network namespace so the agent can't bypass it.
import net from "node:net";
/** Start the observe-only egress proxy on 127.0.0.1:0 (a random free port). */
export async function startEgressProxy(opts) {
const report = (host, port) => {
try {
opts.onHost(host, port);
}
catch { /* best-effort — never break forwarding */ }
};
// True when enforcement is on and this destination is off the allowlist.
const refused = (host, port) => {
if (!opts.allow || opts.allow(host, port))
return false;
try {
opts.onBlock?.(host, port);
}
catch { /* best-effort */ }
return true;
};
const server = net.createServer((client) => {
client.once("data", (first) => {
const head = first.toString("latin1");
const firstLine = head.slice(0, Math.max(0, head.indexOf("\r\n")));
const connect = /^CONNECT\s+([^\s:]+):(\d+)\s+HTTP/i.exec(firstLine);
if (connect) {
// HTTPS tunnel: host:port from the CONNECT line, then blind-pipe both ways.
const host = connect[1];
const port = Number(connect[2]);
report(host, port);
if (refused(host, port)) {
client.write("HTTP/1.1 403 Forbidden\r\n\r\n");
client.destroy();
return;
}
const upstream = net.connect(port, host, () => {
client.write("HTTP/1.1 200 Connection established\r\n\r\n");
client.pipe(upstream);
upstream.pipe(client);
});
upstream.on("error", () => client.destroy());
client.on("error", () => upstream.destroy());
return;
}
// Plain HTTP: the destination is in the Host header. Connect, replay the
// bytes we already consumed, then pipe the rest.
const hostHdr = /\r\nHost:\s*([^\s:\r]+)(?::(\d+))?/i.exec(head);
if (hostHdr) {
const host = hostHdr[1];
const port = hostHdr[2] ? Number(hostHdr[2]) : 80;
report(host, port);
if (refused(host, port)) {
client.write("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n");
client.destroy();
return;
}
const upstream = net.connect(port, host, () => {
upstream.write(first);
client.pipe(upstream);
upstream.pipe(client);
});
upstream.on("error", () => client.destroy());
client.on("error", () => upstream.destroy());
return;
}
client.destroy(); // not HTTP(S) forward-proxy traffic — drop
});
client.on("error", () => client.destroy());
});
await new Promise((res) => server.listen(0, "127.0.0.1", () => res()));
const port = server.address().port;
return {
port,
close: (cb) => server.close(cb),
};
}
// Host re-export of the egress allowlist (Phase 2).
export * from "../layers/security/egress-allowlist.js";
// Host re-export of the egress observer (Phase 1 audit).
export * from "../layers/security/egress-audit.js";
// Host re-export of the egress proxy (Phase 1 audit).
export * from "../layers/security/egress-proxy.js";

Sorry, the diff of this file is too big to display

+32
-1

@@ -9,2 +9,32 @@ # Changelog

## [0.6.0] - 2026-06-22
### Added
- **Network egress allowlist** — a task's agent can be confined to a set of
allowed hosts; anything off the list is refused. Off by default: first you
**observe** (the agent's outbound hosts are logged and shown in the Security
panel's Egress tab), then you turn on enforcement with a toggle and a hosts
editor (defaults cover the model API, npm, GitHub, PyPI). Fails closed — if the
filtering proxy can't start while enforcement is on, the agent gets no network
rather than open network.
- **Provider presets** — add DeepSeek, GLM, Kimi, Qwen, MiniMax, or MiMo as a
one-token profile from Accounts → "Add provider". They run on the Claude CLI
against the provider's Anthropic-compatible endpoint, so they keep the full
toolset and resume natively; pick a provider per task from the task's account
selector.
### Changed
- **aimux 0.17** — provider presets and the cross-CLI handoff.
### Fixed
- **token-pilot allowed by default** (gated/manual) — the agent no longer needs
approval to use the token-efficient tools it's instructed to use.
- **Retired the dead command-mode** — an old soft/enforce check that was never
wired; the OS sandbox plus the command policy are the real enforcement.
- **Per-stage model for non-Claude profiles** — a Codex/GLM profile runs its own
model instead of being handed a Claude tier.
## [0.5.0] - 2026-06-20

@@ -48,3 +78,4 @@

[Unreleased]: https://github.com/Digital-Threads/loom/compare/v0.5.0...master
[Unreleased]: https://github.com/Digital-Threads/loom/compare/v0.6.0...master
[0.6.0]: https://github.com/Digital-Threads/loom/releases/tag/v0.6.0
[0.5.0]: https://github.com/Digital-Threads/loom/releases/tag/v0.5.0

@@ -16,2 +16,5 @@ // Live launcher: one long-lived Claude session per task, run THROUGH aimux.

import { detectSandbox, wrapCommand, sandboxUsable } from "../security/os-sandbox.js";
import { startEgressProxy as startEgressProxyImpl } from "../security/egress-proxy.js";
import { createEgressObserver } from "../security/egress-audit.js";
import { allowsHost } from "../security/egress-allowlist.js";
import { enforcedSettingsPath, tokenPilotOnPath, enforcedSettingsWriteFailed } from "./enforced-settings.js";

@@ -40,2 +43,5 @@ import { listMcp as listMcpServers, writeMcpRunConfig } from "../connectors/mcp.js";

const open = deps.openSession ?? openSession;
const startEgress = deps.startEgressProxy ?? startEgressProxyImpl;
// One egress-audit proxy per live session, closed when the session stops.
const egressProxies = new Map();
const listMcp = deps.listMcp ?? listMcpServers;

@@ -56,3 +62,3 @@ const writeMcp = deps.writeMcpRunConfig ?? writeMcpRunConfig;

};
function getOrOpen(opts) {
async function getOrOpen(opts) {
const existing = sessions.get(opts.sessionId);

@@ -131,2 +137,38 @@ if (existing)

: spawnProcess);
// Egress (loom-xclx): when the security sandbox is on, route the agent's
// traffic through a local proxy that LOGS each destination host and, when
// egress enforcement is enabled, REFUSES hosts off the allowlist. Works even
// where write-confinement is degraded (the proxy is just env). Best-effort: if
// the proxy can't start, run with direct access rather than break the network.
let sessionEnv = opts.env;
if (sandboxOn) {
const policy = deps.egressPolicy?.();
try {
const obs = createEgressObserver({
projectId: opts.env?.LOOM_PROJECT_ID ?? "default",
taskId: opts.env?.LOOM_TASK_ID,
});
const proxy = await startEgress({
onHost: obs.onHost,
onBlock: obs.onBlock,
allow: policy?.enforce ? (host) => allowsHost(host, policy.allow) : undefined,
});
egressProxies.set(opts.sessionId, proxy);
const url = `http://127.0.0.1:${proxy.port}`;
sessionEnv = { ...opts.env, HTTP_PROXY: url, HTTPS_PROXY: url, NO_PROXY: "127.0.0.1,localhost" };
}
catch {
if (policy?.enforce) {
// FAIL CLOSED: enforcement is on but the filtering proxy didn't start.
// Point the agent at a dead local port so its HTTP(S) egress is refused,
// rather than handing it open network (the fail-open hole).
const dead = "http://127.0.0.1:1"; // nothing listens → connection refused
sessionEnv = { ...opts.env, HTTP_PROXY: dead, HTTPS_PROXY: dead, NO_PROXY: "127.0.0.1,localhost" };
note(opts.sessionId, "egress enforcement is on but the proxy failed to start — denied all network (fail-closed)");
}
else {
note(opts.sessionId, "egress audit proxy could not start — agent ran with direct network access");
}
}
}
const session = open(cfg, profile, {

@@ -137,3 +179,3 @@ model: opts.model ?? deps.model,

cwd: opts.cwd,
env: opts.env, // spine env (LOOM_TASK_ID …) so plugin telemetry ties to the task
env: sessionEnv, // spine env (LOOM_TASK_ID …) + egress-audit proxy when sandboxed
settingsPath: enforcedSettingsPath(),

@@ -150,3 +192,3 @@ mcpConfigPath,

async run(prompt, opts) {
const session = getOrOpen(opts);
const session = await getOrOpen(opts);
// Stream assistant text/tool activity to the live view as it arrives.

@@ -169,2 +211,4 @@ const onEvent = opts.onChunk

}
egressProxies.get(sessionId)?.close();
egressProxies.delete(sessionId);
},

@@ -171,0 +215,0 @@ degradedOf: (sessionId) => degraded.get(sessionId) ?? [],

+3
-1
export * from "./config.js";
export * from "./audit.js";
export * from "./mode.js";
export * from "./egress-proxy.js";
export * from "./egress-audit.js";
export * from "./egress-allowlist.js";
export * from "./os-sandbox.js";

@@ -5,0 +7,0 @@ export * from "./path-safety.js";

@@ -32,5 +32,6 @@ // Per-stage model policy — explicit and deterministic, not guessed.

/**
* Resolve the model for a stage. Priority: explicit override > escalation > map.
* Returns a tier alias ("opus"/"sonnet"/"haiku") or the raw override string —
* both are valid `--model` values that aimux passes through.
* Resolve the model for a stage. Priority: explicit override > profile model >
* impl escalation > Claude tier map. Returns a tier alias ("opus"/"sonnet"/
* "haiku"), the profile's model, or the raw override — all valid `--model`
* values the provider's adapter passes through.
*/

@@ -40,2 +41,6 @@ export function resolveStageModel(stage, opts = {}) {

return opts.override;
// A non-Claude profile pins its own model — the Claude tiers + impl escalation
// below don't apply off-Claude.
if (opts.profileModel)
return opts.profileModel;
if (stage === "impl" && (opts.relocations ?? 0) >= IMPL_ESCALATE_AFTER)

@@ -42,0 +47,0 @@ return "opus";

@@ -1,2 +0,2 @@

import { loadConfig, saveConfig, addProfile, removeProfile, classifyProfile, expandHome, checkAllProfiles, unifyAllSessions, launchProfile, getProfile, saveActiveProfile, } from "@digital-threads/aimux/core";
import { loadConfig, saveConfig, addProfile, removeProfile, classifyProfile, expandHome, checkAllProfiles, unifyAllSessions, launchProfile, getProfile, saveActiveProfile, PROVIDER_PRESETS, providerEnv, writeProfileDotEnv, seedApiClaudeJson, ensureProfileDir, } from "@digital-threads/aimux/core";
export function listSubscriptions() {

@@ -47,2 +47,33 @@ const cfg = loadConfig();

}
/** The one-command provider presets aimux ships (deepseek/glm/kimi/…). Each runs
* on the claude CLI against the provider's Anthropic-compatible endpoint, so it
* inherits the full claude brain + native resume. */
export function listProviderPresets() {
return Object.entries(PROVIDER_PRESETS).map(([key, p]) => ({ key, label: p.label, baseUrl: p.baseUrl }));
}
/** Add a profile from a provider preset: a claude-CLI profile pointed at the
* provider's endpoint, with the token written to its private .env. */
export function addProviderPreset(name, providerKey, token) {
try {
const preset = PROVIDER_PRESETS[providerKey.toLowerCase()];
if (!preset)
return { ok: false, error: `unknown provider: ${providerKey}` };
if (!name.trim())
return { ok: false, error: "profile name required" };
if (!token.trim())
return { ok: false, error: "API token required" };
const cfg = loadConfig();
if (!cfg)
return { ok: false, error: "no aimux config" };
const updated = addProfile(cfg, name, { cli: "claude", model: preset.models.default });
const profilePath = ensureProfileDir(updated, name);
saveConfig(updated);
writeProfileDotEnv(profilePath, providerEnv(preset, token)); // ANTHROPIC_BASE_URL + token + model
seedApiClaudeJson(profilePath);
return { ok: true };
}
catch (e) {
return { ok: false, error: e.message };
}
}
export function removeSubscription(name) {

@@ -49,0 +80,0 @@ try {

@@ -12,3 +12,3 @@ // ClaudeRuntime — the single AgentRuntime implementation. ALL Claude-specific

id: "claude",
launcher: deps.launcher ?? createAimuxLiveLauncher({ sandbox: deps.sandbox }),
launcher: deps.launcher ?? createAimuxLiveLauncher({ sandbox: deps.sandbox, egressPolicy: deps.egressPolicy }),
skills: {

@@ -15,0 +15,0 @@ list: listSkills,

{
"name": "@digital-threads/loom",
"version": "0.5.0",
"version": "0.6.0",
"type": "module",

@@ -58,3 +58,3 @@ "description": "Loom — local AI-dev orchestrator: give it a task and it runs the work through an analysis → spec → code → review → PR pipeline on a board, with cost, reasoning memory and multi-account support. Public beta.",

"dependencies": {
"@digital-threads/aimux": "^0.15.0",
"@digital-threads/aimux": "^0.17.0",
"@hono/node-server": "^2.0.5",

@@ -61,0 +61,0 @@ "better-sqlite3": "^12.10.1",

@@ -7,3 +7,3 @@ <!doctype html>

<title>Loom</title>
<script type="module" crossorigin src="/assets/index-DyOmZlmI.js"></script>
<script type="module" crossorigin src="/assets/index-Ci37oIBr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DOvANPEQ.css">

@@ -10,0 +10,0 @@ </head>

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display