@digital-threads/loom
Advanced tools
| // 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) ?? [], |
| 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, |
+2
-2
| { | ||
| "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
Network access
Supply chain riskThis module accesses the network.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
2082529
0.86%261
2.35%15818
3.49%23
4.55%+ Added
- Removed