@gtc6244/immediacy-daemon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/actions.d.ts +22 -0
- package/dist/src/actions.js +44 -0
- package/dist/src/actions.js.map +1 -0
- package/dist/src/agent/agent.d.ts +89 -0
- package/dist/src/agent/agent.js +22 -0
- package/dist/src/agent/agent.js.map +1 -0
- package/dist/src/agent/claude.d.ts +70 -0
- package/dist/src/agent/claude.js +404 -0
- package/dist/src/agent/claude.js.map +1 -0
- package/dist/src/agent/codex.d.ts +44 -0
- package/dist/src/agent/codex.js +303 -0
- package/dist/src/agent/codex.js.map +1 -0
- package/dist/src/agent/mock.d.ts +31 -0
- package/dist/src/agent/mock.js +155 -0
- package/dist/src/agent/mock.js.map +1 -0
- package/dist/src/agent/model-error.d.ts +8 -0
- package/dist/src/agent/model-error.js +25 -0
- package/dist/src/agent/model-error.js.map +1 -0
- package/dist/src/agent/openrouter.d.ts +38 -0
- package/dist/src/agent/openrouter.js +60 -0
- package/dist/src/agent/openrouter.js.map +1 -0
- package/dist/src/agent/workspace-scope.d.ts +25 -0
- package/dist/src/agent/workspace-scope.js +71 -0
- package/dist/src/agent/workspace-scope.js.map +1 -0
- package/dist/src/allowlist.d.ts +56 -0
- package/dist/src/allowlist.js +141 -0
- package/dist/src/allowlist.js.map +1 -0
- package/dist/src/chatlog.d.ts +50 -0
- package/dist/src/chatlog.js +179 -0
- package/dist/src/chatlog.js.map +1 -0
- package/dist/src/cli/paths.d.ts +40 -0
- package/dist/src/cli/paths.js +76 -0
- package/dist/src/cli/paths.js.map +1 -0
- package/dist/src/cli/service.d.ts +20 -0
- package/dist/src/cli/service.js +245 -0
- package/dist/src/cli/service.js.map +1 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +227 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/config.d.ts +33 -0
- package/dist/src/config.js +122 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/credentials.d.ts +38 -0
- package/dist/src/credentials.js +120 -0
- package/dist/src/credentials.js.map +1 -0
- package/dist/src/crypto.d.ts +49 -0
- package/dist/src/crypto.js +202 -0
- package/dist/src/crypto.js.map +1 -0
- package/dist/src/daemon-identity.d.ts +31 -0
- package/dist/src/daemon-identity.js +97 -0
- package/dist/src/daemon-identity.js.map +1 -0
- package/dist/src/envelope.d.ts +14 -0
- package/dist/src/envelope.js +28 -0
- package/dist/src/envelope.js.map +1 -0
- package/dist/src/events.d.ts +40 -0
- package/dist/src/events.js +24 -0
- package/dist/src/events.js.map +1 -0
- package/dist/src/forward.d.ts +51 -0
- package/dist/src/forward.js +170 -0
- package/dist/src/forward.js.map +1 -0
- package/dist/src/fs-scope.d.ts +17 -0
- package/dist/src/fs-scope.js +47 -0
- package/dist/src/fs-scope.js.map +1 -0
- package/dist/src/github.d.ts +70 -0
- package/dist/src/github.js +178 -0
- package/dist/src/github.js.map +1 -0
- package/dist/src/gitops.d.ts +113 -0
- package/dist/src/gitops.js +557 -0
- package/dist/src/gitops.js.map +1 -0
- package/dist/src/hub.d.ts +50 -0
- package/dist/src/hub.js +92 -0
- package/dist/src/hub.js.map +1 -0
- package/dist/src/index.d.ts +22 -0
- package/dist/src/index.js +56 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/jobs/scheduler.d.ts +94 -0
- package/dist/src/jobs/scheduler.js +373 -0
- package/dist/src/jobs/scheduler.js.map +1 -0
- package/dist/src/jobs/store.d.ts +33 -0
- package/dist/src/jobs/store.js +111 -0
- package/dist/src/jobs/store.js.map +1 -0
- package/dist/src/log.d.ts +61 -0
- package/dist/src/log.js +166 -0
- package/dist/src/log.js.map +1 -0
- package/dist/src/migrate-state-dir.d.ts +1 -0
- package/dist/src/migrate-state-dir.js +47 -0
- package/dist/src/migrate-state-dir.js.map +1 -0
- package/dist/src/models.d.ts +36 -0
- package/dist/src/models.js +71 -0
- package/dist/src/models.js.map +1 -0
- package/dist/src/permission-gate.d.ts +48 -0
- package/dist/src/permission-gate.js +93 -0
- package/dist/src/permission-gate.js.map +1 -0
- package/dist/src/permission-scope.d.ts +77 -0
- package/dist/src/permission-scope.js +244 -0
- package/dist/src/permission-scope.js.map +1 -0
- package/dist/src/ports.d.ts +71 -0
- package/dist/src/ports.js +216 -0
- package/dist/src/ports.js.map +1 -0
- package/dist/src/projects.d.ts +83 -0
- package/dist/src/projects.js +205 -0
- package/dist/src/projects.js.map +1 -0
- package/dist/src/protocol.d.ts +1226 -0
- package/dist/src/protocol.js +16 -0
- package/dist/src/protocol.js.map +1 -0
- package/dist/src/repo-sync.d.ts +49 -0
- package/dist/src/repo-sync.js +83 -0
- package/dist/src/repo-sync.js.map +1 -0
- package/dist/src/server.d.ts +33 -0
- package/dist/src/server.js +973 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/terminal.d.ts +32 -0
- package/dist/src/terminal.js +92 -0
- package/dist/src/terminal.js.map +1 -0
- package/dist/src/titler.d.ts +15 -0
- package/dist/src/titler.js +72 -0
- package/dist/src/titler.js.map +1 -0
- package/dist/src/types.d.ts +99 -0
- package/dist/src/types.js +3 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/usage-limit.d.ts +49 -0
- package/dist/src/usage-limit.js +83 -0
- package/dist/src/usage-limit.js.map +1 -0
- package/dist/src/usage.d.ts +23 -0
- package/dist/src/usage.js +63 -0
- package/dist/src/usage.js.map +1 -0
- package/dist/src/workspace.d.ts +303 -0
- package/dist/src/workspace.js +1086 -0
- package/dist/src/workspace.js.map +1 -0
- package/dist/src/worktree.d.ts +133 -0
- package/dist/src/worktree.js +561 -0
- package/dist/src/worktree.js.map +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouterAgent — an OpenRouter-hosted model driven through the Claude Agent
|
|
3
|
+
* SDK.
|
|
4
|
+
*
|
|
5
|
+
* OpenRouter exposes an Anthropic-compatible Messages endpoint ("Anthropic
|
|
6
|
+
* skin") that passes through extended thinking and native tool use, so the SDK
|
|
7
|
+
* that already powers ClaudeAgent works unchanged — we just retarget it. That
|
|
8
|
+
* means every guarantee ClaudeAgent gives (worktree-scoped auto-approve, phone
|
|
9
|
+
* permission gate on escape, streaming, session resume, no `bypassPermissions`)
|
|
10
|
+
* holds identically here; the only difference is where the tokens are billed.
|
|
11
|
+
*
|
|
12
|
+
* We subclass ClaudeAgent and hand it SDK `env` overrides instead of touching
|
|
13
|
+
* `process.env`, so a native-Claude workspace and an OpenRouter workspace can be
|
|
14
|
+
* live at the same time without stepping on each other's credentials.
|
|
15
|
+
*
|
|
16
|
+
* Config (nothing sensitive in source):
|
|
17
|
+
* OpenRouter API key required — billing + routing. Resolved by the daemon's
|
|
18
|
+
* CredentialsStore (app-managed key, falling back to the
|
|
19
|
+
* OPENROUTER_API_KEY env var) and passed to the ctor.
|
|
20
|
+
* OPENROUTER_BASE_URL default https://openrouter.ai/api — override for a
|
|
21
|
+
* self-hosted proxy or a region-specific endpoint
|
|
22
|
+
*
|
|
23
|
+
* The model id (`ctx.model`) is an OpenRouter `vendor/model` slug (see
|
|
24
|
+
* models.ts); it flows to the SDK's `model` option via ClaudeAgent. We also pin
|
|
25
|
+
* every Claude Code "model class" to that same slug so a stray built-in default
|
|
26
|
+
* (e.g. a background haiku call) can't be sent to OpenRouter, which would only
|
|
27
|
+
* recognize its own slugs.
|
|
28
|
+
*/
|
|
29
|
+
import { ClaudeAgent } from "./claude.js";
|
|
30
|
+
import type { AgentContext } from "./agent.js";
|
|
31
|
+
export declare class OpenRouterAgent extends ClaudeAgent {
|
|
32
|
+
/**
|
|
33
|
+
* @param apiKey OpenRouter key resolved by the daemon (CredentialsStore →
|
|
34
|
+
* app-managed key or the OPENROUTER_API_KEY env fallback). Empty → the
|
|
35
|
+
* session fails auth (OpenRouter has no login fallback).
|
|
36
|
+
*/
|
|
37
|
+
constructor(ctx: AgentContext, apiKey?: string);
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenRouterAgent — an OpenRouter-hosted model driven through the Claude Agent
|
|
3
|
+
* SDK.
|
|
4
|
+
*
|
|
5
|
+
* OpenRouter exposes an Anthropic-compatible Messages endpoint ("Anthropic
|
|
6
|
+
* skin") that passes through extended thinking and native tool use, so the SDK
|
|
7
|
+
* that already powers ClaudeAgent works unchanged — we just retarget it. That
|
|
8
|
+
* means every guarantee ClaudeAgent gives (worktree-scoped auto-approve, phone
|
|
9
|
+
* permission gate on escape, streaming, session resume, no `bypassPermissions`)
|
|
10
|
+
* holds identically here; the only difference is where the tokens are billed.
|
|
11
|
+
*
|
|
12
|
+
* We subclass ClaudeAgent and hand it SDK `env` overrides instead of touching
|
|
13
|
+
* `process.env`, so a native-Claude workspace and an OpenRouter workspace can be
|
|
14
|
+
* live at the same time without stepping on each other's credentials.
|
|
15
|
+
*
|
|
16
|
+
* Config (nothing sensitive in source):
|
|
17
|
+
* OpenRouter API key required — billing + routing. Resolved by the daemon's
|
|
18
|
+
* CredentialsStore (app-managed key, falling back to the
|
|
19
|
+
* OPENROUTER_API_KEY env var) and passed to the ctor.
|
|
20
|
+
* OPENROUTER_BASE_URL default https://openrouter.ai/api — override for a
|
|
21
|
+
* self-hosted proxy or a region-specific endpoint
|
|
22
|
+
*
|
|
23
|
+
* The model id (`ctx.model`) is an OpenRouter `vendor/model` slug (see
|
|
24
|
+
* models.ts); it flows to the SDK's `model` option via ClaudeAgent. We also pin
|
|
25
|
+
* every Claude Code "model class" to that same slug so a stray built-in default
|
|
26
|
+
* (e.g. a background haiku call) can't be sent to OpenRouter, which would only
|
|
27
|
+
* recognize its own slugs.
|
|
28
|
+
*/
|
|
29
|
+
import { ClaudeAgent } from "./claude.js";
|
|
30
|
+
const OPENROUTER_BASE_URL = process.env.OPENROUTER_BASE_URL ?? "https://openrouter.ai/api";
|
|
31
|
+
export class OpenRouterAgent extends ClaudeAgent {
|
|
32
|
+
/**
|
|
33
|
+
* @param apiKey OpenRouter key resolved by the daemon (CredentialsStore →
|
|
34
|
+
* app-managed key or the OPENROUTER_API_KEY env fallback). Empty → the
|
|
35
|
+
* session fails auth (OpenRouter has no login fallback).
|
|
36
|
+
*/
|
|
37
|
+
constructor(ctx, apiKey) {
|
|
38
|
+
const key = apiKey ?? "";
|
|
39
|
+
// OpenRouter authenticates via ANTHROPIC_AUTH_TOKEN (a bearer token);
|
|
40
|
+
// ANTHROPIC_API_KEY must be blank so the SDK doesn't prefer a stored
|
|
41
|
+
// Anthropic key and bypass the token.
|
|
42
|
+
const slug = ctx.model;
|
|
43
|
+
super(ctx, {
|
|
44
|
+
ANTHROPIC_BASE_URL: OPENROUTER_BASE_URL,
|
|
45
|
+
ANTHROPIC_AUTH_TOKEN: key,
|
|
46
|
+
ANTHROPIC_API_KEY: "",
|
|
47
|
+
// Route every model class to the selected slug; leaving these unset lets
|
|
48
|
+
// the CLI fall back to Anthropic default ids OpenRouter can't serve.
|
|
49
|
+
...(slug
|
|
50
|
+
? {
|
|
51
|
+
ANTHROPIC_MODEL: slug,
|
|
52
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: slug,
|
|
53
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: slug,
|
|
54
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: slug,
|
|
55
|
+
}
|
|
56
|
+
: {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=openrouter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openrouter.js","sourceRoot":"","sources":["../../../src/agent/openrouter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAG1C,MAAM,mBAAmB,GACvB,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,2BAA2B,CAAC;AAEjE,MAAM,OAAO,eAAgB,SAAQ,WAAW;IAC9C;;;;OAIG;IACH,YAAY,GAAiB,EAAE,MAAe;QAC5C,MAAM,GAAG,GAAG,MAAM,IAAI,EAAE,CAAC;QACzB,sEAAsE;QACtE,qEAAqE;QACrE,sCAAsC;QACtC,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC;QACvB,KAAK,CAAC,GAAG,EAAE;YACT,kBAAkB,EAAE,mBAAmB;YACvC,oBAAoB,EAAE,GAAG;YACzB,iBAAiB,EAAE,EAAE;YACrB,yEAAyE;YACzE,qEAAqE;YACrE,GAAG,CAAC,IAAI;gBACN,CAAC,CAAC;oBACE,eAAe,EAAE,IAAI;oBACrB,4BAA4B,EAAE,IAAI;oBAClC,8BAA8B,EAAE,IAAI;oBACpC,6BAA6B,EAAE,IAAI;iBACpC;gBACH,CAAC,CAAC,EAAE,CAAC;SACR,CAAC,CAAC;IACL,CAAC;CACF"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace scoping for Claude's per-tool-call permission gate.
|
|
3
|
+
*
|
|
4
|
+
* A workspace *is* its git worktree, and the Claude session runs with
|
|
5
|
+
* `cwd = worktreePath`. Anything that stays inside that folder is, by policy,
|
|
6
|
+
* fully trusted and runs without a phone round-trip — the same trust boundary
|
|
7
|
+
* Codex enforces natively with `-s workspace-write`. Only calls that reach
|
|
8
|
+
* *outside* the worktree (or that we can't bound to it) still defer to the phone.
|
|
9
|
+
*
|
|
10
|
+
* This keeps the two backends' permission models consistent: work inside the
|
|
11
|
+
* workspace is free; escaping it is gated.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* True when a tool call is confined to `worktreePath` and may be auto-approved.
|
|
15
|
+
*
|
|
16
|
+
* - Bash and the shell tools execute with `cwd = worktreePath`, so they are
|
|
17
|
+
* treated as workspace-scoped by policy (this is the deliberate "bash scripts
|
|
18
|
+
* in the workspace just run" decision).
|
|
19
|
+
* - File and search tools are scoped by resolving their target path against the
|
|
20
|
+
* worktree; an absolute or `..`-escaping path outside the worktree is NOT
|
|
21
|
+
* scoped and falls through to the phone.
|
|
22
|
+
* - Everything else (WebFetch, WebSearch, MCP tools, unknown tools) is not
|
|
23
|
+
* workspace-scoped and stays gated.
|
|
24
|
+
*/
|
|
25
|
+
export declare function isWorkspaceScoped(tool: string, input: Record<string, unknown>, worktreePath: string): boolean;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace scoping for Claude's per-tool-call permission gate.
|
|
3
|
+
*
|
|
4
|
+
* A workspace *is* its git worktree, and the Claude session runs with
|
|
5
|
+
* `cwd = worktreePath`. Anything that stays inside that folder is, by policy,
|
|
6
|
+
* fully trusted and runs without a phone round-trip — the same trust boundary
|
|
7
|
+
* Codex enforces natively with `-s workspace-write`. Only calls that reach
|
|
8
|
+
* *outside* the worktree (or that we can't bound to it) still defer to the phone.
|
|
9
|
+
*
|
|
10
|
+
* This keeps the two backends' permission models consistent: work inside the
|
|
11
|
+
* workspace is free; escaping it is gated.
|
|
12
|
+
*/
|
|
13
|
+
import { isInside } from "../fs-scope.js";
|
|
14
|
+
/** Input fields that name a filesystem target we can bound to the worktree. */
|
|
15
|
+
const PATH_FIELDS = ["file_path", "path", "notebook_path"];
|
|
16
|
+
/**
|
|
17
|
+
* True when a tool call is confined to `worktreePath` and may be auto-approved.
|
|
18
|
+
*
|
|
19
|
+
* - Bash and the shell tools execute with `cwd = worktreePath`, so they are
|
|
20
|
+
* treated as workspace-scoped by policy (this is the deliberate "bash scripts
|
|
21
|
+
* in the workspace just run" decision).
|
|
22
|
+
* - File and search tools are scoped by resolving their target path against the
|
|
23
|
+
* worktree; an absolute or `..`-escaping path outside the worktree is NOT
|
|
24
|
+
* scoped and falls through to the phone.
|
|
25
|
+
* - Everything else (WebFetch, WebSearch, MCP tools, unknown tools) is not
|
|
26
|
+
* workspace-scoped and stays gated.
|
|
27
|
+
*/
|
|
28
|
+
export function isWorkspaceScoped(tool, input, worktreePath) {
|
|
29
|
+
switch (tool) {
|
|
30
|
+
// Confined to cwd (= the worktree) by construction, or path-bounded below.
|
|
31
|
+
case "Bash":
|
|
32
|
+
case "BashOutput":
|
|
33
|
+
case "KillShell":
|
|
34
|
+
case "Glob":
|
|
35
|
+
case "Grep":
|
|
36
|
+
case "LS":
|
|
37
|
+
case "Read":
|
|
38
|
+
case "Write":
|
|
39
|
+
case "Edit":
|
|
40
|
+
case "MultiEdit":
|
|
41
|
+
case "NotebookEdit":
|
|
42
|
+
case "NotebookRead":
|
|
43
|
+
return pathsWithin(input, worktreePath);
|
|
44
|
+
default:
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* True when every filesystem path named in `input` resolves inside the worktree.
|
|
50
|
+
* Tools with no path field (e.g. Bash) are scoped by cwd and return true here.
|
|
51
|
+
*
|
|
52
|
+
* Fails closed: a path field that is present but not a usable in-worktree string
|
|
53
|
+
* (empty, whitespace, an array/object, or anything resolving outside) makes the
|
|
54
|
+
* whole call NOT workspace-scoped, so it defers to the phone rather than slipping
|
|
55
|
+
* through the auto-approve path on an unmodeled value shape.
|
|
56
|
+
*/
|
|
57
|
+
function pathsWithin(input, worktreePath) {
|
|
58
|
+
for (const field of PATH_FIELDS) {
|
|
59
|
+
if (!(field in input))
|
|
60
|
+
continue;
|
|
61
|
+
const value = input[field];
|
|
62
|
+
if (typeof value !== "string")
|
|
63
|
+
return false; // present but non-string → gate
|
|
64
|
+
if (value.trim().length === 0)
|
|
65
|
+
return false; // empty/whitespace → gate
|
|
66
|
+
if (!isInside(worktreePath, value))
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=workspace-scope.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-scope.js","sourceRoot":"","sources":["../../../src/agent/workspace-scope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAE1C,+EAA+E;AAC/E,MAAM,WAAW,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,eAAe,CAAU,CAAC;AAEpE;;;;;;;;;;;GAWG;AACH,MAAM,UAAU,iBAAiB,CAC/B,IAAY,EACZ,KAA8B,EAC9B,YAAoB;IAEpB,QAAQ,IAAI,EAAE,CAAC;QACb,2EAA2E;QAC3E,KAAK,MAAM,CAAC;QACZ,KAAK,YAAY,CAAC;QAClB,KAAK,WAAW,CAAC;QACjB,KAAK,MAAM,CAAC;QACZ,KAAK,MAAM,CAAC;QACZ,KAAK,IAAI,CAAC;QACV,KAAK,MAAM,CAAC;QACZ,KAAK,OAAO,CAAC;QACb,KAAK,MAAM,CAAC;QACZ,KAAK,WAAW,CAAC;QACjB,KAAK,cAAc,CAAC;QACpB,KAAK,cAAc;YACjB,OAAO,WAAW,CAAC,KAAK,EAAE,YAAY,CAAC,CAAC;QAC1C;YACE,OAAO,KAAK,CAAC;IACjB,CAAC;AACH,CAAC;AAED;;;;;;;;GAQG;AACH,SAAS,WAAW,CAAC,KAA8B,EAAE,YAAoB;IACvE,KAAK,MAAM,KAAK,IAAI,WAAW,EAAE,CAAC;QAChC,IAAI,CAAC,CAAC,KAAK,IAAI,KAAK,CAAC;YAAE,SAAS;QAChC,MAAM,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC;QAC3B,IAAI,OAAO,KAAK,KAAK,QAAQ;YAAE,OAAO,KAAK,CAAC,CAAC,gCAAgC;QAC7E,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC,CAAC,0BAA0B;QACvE,IAAI,CAAC,QAAQ,CAAC,YAAY,EAAE,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;IACnD,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AllowlistStore — the daemon-global "Allow all" list, persisted to disk.
|
|
3
|
+
*
|
|
4
|
+
* When a client answers a permission request with `allow_all`, the daemon
|
|
5
|
+
* derives an {@link AllowRule} from that call (see permission-scope.ts) and adds
|
|
6
|
+
* it here. Thereafter every gated tool call is checked against these rules
|
|
7
|
+
* *before* it defers to the phone — a match auto-approves. The list is
|
|
8
|
+
* daemon-global (one list for every workspace, keyed on absolute dirs / hosts /
|
|
9
|
+
* tool names) and durable across restarts: it lives in
|
|
10
|
+
* `~/.immediacy/allowlist.json`.
|
|
11
|
+
*
|
|
12
|
+
* Storage mirrors config.ts: an in-memory cache, a serialized atomic-rename
|
|
13
|
+
* persist() so concurrent add()s can't tear the file, and world-readable 0644
|
|
14
|
+
* (no secret material — just categories the user chose to trust). Each entry
|
|
15
|
+
* keeps light provenance (when it was granted, and the call that created it) so
|
|
16
|
+
* the admin list can show *why* a rule exists.
|
|
17
|
+
*/
|
|
18
|
+
import { type AllowRule } from "./permission-scope.js";
|
|
19
|
+
/** A stored grant: the matching rule plus provenance for the admin list. */
|
|
20
|
+
export interface AllowEntry {
|
|
21
|
+
/** Stable identity (see ruleKey); also this entry's handle for revoke. */
|
|
22
|
+
key: string;
|
|
23
|
+
rule: AllowRule;
|
|
24
|
+
/** One-line human description, e.g. "all requests to example.com". */
|
|
25
|
+
description: string;
|
|
26
|
+
/** Epoch ms the grant was made. */
|
|
27
|
+
createdMs: number;
|
|
28
|
+
/** The workspace and tool the grant was created from (context, not scope). */
|
|
29
|
+
fromWorkspace?: string;
|
|
30
|
+
fromTool?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare class AllowlistStore {
|
|
33
|
+
/** Insertion-ordered cache, keyed by ruleKey for O(1) dedup + revoke. */
|
|
34
|
+
private entries;
|
|
35
|
+
/** Serializes writes so two concurrent add()s can't interleave. */
|
|
36
|
+
private writeChain;
|
|
37
|
+
load(): Promise<void>;
|
|
38
|
+
/** True when a persisted rule already covers this call. */
|
|
39
|
+
isAllowed(tool: string, input: Record<string, unknown>, worktreePath: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Grant "allow all" for a call: derive its rule, add it (dedup by key), and
|
|
42
|
+
* persist. Returns the entry (new or pre-existing), or null if the call can't
|
|
43
|
+
* be generalized — in which case the caller should fall back to a one-shot
|
|
44
|
+
* allow. Idempotent: re-granting the same category returns the existing entry
|
|
45
|
+
* and does not rewrite the file.
|
|
46
|
+
*/
|
|
47
|
+
allowFromCall(tool: string, input: Record<string, unknown>, worktreePath: string, fromWorkspace?: string): Promise<AllowEntry | null>;
|
|
48
|
+
/** Snapshot of every grant (newest last), for the admin list. */
|
|
49
|
+
list(): AllowEntry[];
|
|
50
|
+
/** Drop one grant by key. Returns true if it existed. Persists on change. */
|
|
51
|
+
revoke(key: string): Promise<boolean>;
|
|
52
|
+
/** Drop every grant. Persists (writes an empty list). */
|
|
53
|
+
clear(): Promise<void>;
|
|
54
|
+
private rules;
|
|
55
|
+
private persist;
|
|
56
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AllowlistStore — the daemon-global "Allow all" list, persisted to disk.
|
|
3
|
+
*
|
|
4
|
+
* When a client answers a permission request with `allow_all`, the daemon
|
|
5
|
+
* derives an {@link AllowRule} from that call (see permission-scope.ts) and adds
|
|
6
|
+
* it here. Thereafter every gated tool call is checked against these rules
|
|
7
|
+
* *before* it defers to the phone — a match auto-approves. The list is
|
|
8
|
+
* daemon-global (one list for every workspace, keyed on absolute dirs / hosts /
|
|
9
|
+
* tool names) and durable across restarts: it lives in
|
|
10
|
+
* `~/.immediacy/allowlist.json`.
|
|
11
|
+
*
|
|
12
|
+
* Storage mirrors config.ts: an in-memory cache, a serialized atomic-rename
|
|
13
|
+
* persist() so concurrent add()s can't tear the file, and world-readable 0644
|
|
14
|
+
* (no secret material — just categories the user chose to trust). Each entry
|
|
15
|
+
* keeps light provenance (when it was granted, and the call that created it) so
|
|
16
|
+
* the admin list can show *why* a rule exists.
|
|
17
|
+
*/
|
|
18
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { anyRuleMatches, deriveRule, describeRule, ruleKey, sanitizeRule, } from "./permission-scope.js";
|
|
22
|
+
/**
|
|
23
|
+
* Resolved lazily (per load/persist) rather than captured at module load, so a
|
|
24
|
+
* test — or any runtime override — can point ALLOWLIST_FILE at a scratch path
|
|
25
|
+
* before the store touches disk. A module-level `const` bound the default before
|
|
26
|
+
* the test's `process.env` assignment ran, which silently routed the store's
|
|
27
|
+
* writes (including `clear()`) at the user's real ~/.immediacy/allowlist.json.
|
|
28
|
+
*/
|
|
29
|
+
function allowlistFile() {
|
|
30
|
+
return process.env.ALLOWLIST_FILE ?? join(homedir(), ".immediacy", "allowlist.json");
|
|
31
|
+
}
|
|
32
|
+
export class AllowlistStore {
|
|
33
|
+
/** Insertion-ordered cache, keyed by ruleKey for O(1) dedup + revoke. */
|
|
34
|
+
entries = new Map();
|
|
35
|
+
/** Serializes writes so two concurrent add()s can't interleave. */
|
|
36
|
+
writeChain = Promise.resolve();
|
|
37
|
+
async load() {
|
|
38
|
+
try {
|
|
39
|
+
const raw = await readFile(allowlistFile(), "utf8");
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
const list = Array.isArray(parsed) ? parsed : [];
|
|
42
|
+
for (const item of list) {
|
|
43
|
+
const entry = sanitizeEntry(item);
|
|
44
|
+
if (entry)
|
|
45
|
+
this.entries.set(entry.key, entry);
|
|
46
|
+
}
|
|
47
|
+
console.log(`[allowlist] restored ${this.entries.size} rule(s)`);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* no prior state — start empty */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** True when a persisted rule already covers this call. */
|
|
54
|
+
isAllowed(tool, input, worktreePath) {
|
|
55
|
+
if (this.entries.size === 0)
|
|
56
|
+
return false;
|
|
57
|
+
return anyRuleMatches(this.rules(), tool, input, worktreePath);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Grant "allow all" for a call: derive its rule, add it (dedup by key), and
|
|
61
|
+
* persist. Returns the entry (new or pre-existing), or null if the call can't
|
|
62
|
+
* be generalized — in which case the caller should fall back to a one-shot
|
|
63
|
+
* allow. Idempotent: re-granting the same category returns the existing entry
|
|
64
|
+
* and does not rewrite the file.
|
|
65
|
+
*/
|
|
66
|
+
async allowFromCall(tool, input, worktreePath, fromWorkspace) {
|
|
67
|
+
const rule = deriveRule(tool, input, worktreePath);
|
|
68
|
+
if (!rule)
|
|
69
|
+
return null;
|
|
70
|
+
const key = ruleKey(rule);
|
|
71
|
+
const existing = this.entries.get(key);
|
|
72
|
+
if (existing)
|
|
73
|
+
return existing;
|
|
74
|
+
const entry = {
|
|
75
|
+
key,
|
|
76
|
+
rule,
|
|
77
|
+
description: describeRule(rule),
|
|
78
|
+
createdMs: Date.now(),
|
|
79
|
+
fromWorkspace,
|
|
80
|
+
fromTool: tool,
|
|
81
|
+
};
|
|
82
|
+
this.entries.set(key, entry);
|
|
83
|
+
await this.persist();
|
|
84
|
+
return entry;
|
|
85
|
+
}
|
|
86
|
+
/** Snapshot of every grant (newest last), for the admin list. */
|
|
87
|
+
list() {
|
|
88
|
+
return [...this.entries.values()];
|
|
89
|
+
}
|
|
90
|
+
/** Drop one grant by key. Returns true if it existed. Persists on change. */
|
|
91
|
+
async revoke(key) {
|
|
92
|
+
if (!this.entries.delete(key))
|
|
93
|
+
return false;
|
|
94
|
+
await this.persist();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
/** Drop every grant. Persists (writes an empty list). */
|
|
98
|
+
async clear() {
|
|
99
|
+
if (this.entries.size === 0)
|
|
100
|
+
return;
|
|
101
|
+
this.entries.clear();
|
|
102
|
+
await this.persist();
|
|
103
|
+
}
|
|
104
|
+
rules() {
|
|
105
|
+
return [...this.entries.values()].map((e) => e.rule);
|
|
106
|
+
}
|
|
107
|
+
persist() {
|
|
108
|
+
// Snapshot synchronously so a later add() can't race this write, then chain
|
|
109
|
+
// onto any in-flight write so concurrent calls serialize (mirrors config.ts).
|
|
110
|
+
const snapshot = JSON.stringify([...this.entries.values()], null, 2);
|
|
111
|
+
this.writeChain = this.writeChain
|
|
112
|
+
.catch(() => { })
|
|
113
|
+
.then(async () => {
|
|
114
|
+
const file = allowlistFile();
|
|
115
|
+
await mkdir(dirname(file), { recursive: true });
|
|
116
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
117
|
+
await writeFile(tmp, snapshot, "utf8");
|
|
118
|
+
await rename(tmp, file);
|
|
119
|
+
});
|
|
120
|
+
return this.writeChain;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Coerce one parsed JSON item into a valid AllowEntry, or null if malformed. */
|
|
124
|
+
function sanitizeEntry(input) {
|
|
125
|
+
if (!input || typeof input !== "object")
|
|
126
|
+
return null;
|
|
127
|
+
const o = input;
|
|
128
|
+
const rule = sanitizeRule(o.rule);
|
|
129
|
+
if (!rule)
|
|
130
|
+
return null;
|
|
131
|
+
const key = ruleKey(rule);
|
|
132
|
+
return {
|
|
133
|
+
key,
|
|
134
|
+
rule,
|
|
135
|
+
description: typeof o.description === "string" ? o.description : describeRule(rule),
|
|
136
|
+
createdMs: typeof o.createdMs === "number" ? o.createdMs : Date.now(),
|
|
137
|
+
fromWorkspace: typeof o.fromWorkspace === "string" ? o.fromWorkspace : undefined,
|
|
138
|
+
fromTool: typeof o.fromTool === "string" ? o.fromTool : undefined,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=allowlist.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"allowlist.js","sourceRoot":"","sources":["../../src/allowlist.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatLog — a durable, per-workspace running transcript.
|
|
3
|
+
*
|
|
4
|
+
* The daemon already broadcasts every prompt/response as an `event.*` frame,
|
|
5
|
+
* but those are ephemeral: a phone that reboots (or connects fresh) has missed
|
|
6
|
+
* them. ChatLog is the memory. Each workspace gets an append-only JSONL file
|
|
7
|
+
* under `~/.immediacy/chatlogs/<wsId>.jsonl`; one `ChatEntry` per line. New
|
|
8
|
+
* entries append (cheap); the only mutation is stamping a permission's
|
|
9
|
+
* `resolved` decision, which rewrites that workspace's file from memory.
|
|
10
|
+
*
|
|
11
|
+
* On startup `load()` reads the files back so history survives a daemon
|
|
12
|
+
* restart too, mirroring how `state.json` re-hydrates workspaces.
|
|
13
|
+
*/
|
|
14
|
+
import type { ChatEntry } from "./protocol.js";
|
|
15
|
+
export declare class ChatLog {
|
|
16
|
+
private dir;
|
|
17
|
+
private mem;
|
|
18
|
+
/**
|
|
19
|
+
* Per-workspace write chain. `append` and `resolvePermission`→`rewrite` are
|
|
20
|
+
* fired-and-forgotten from many call sites; without serialization an
|
|
21
|
+
* appendFile can interleave with a full-file rewrite and corrupt or truncate
|
|
22
|
+
* the JSONL. Every disk write for a workspace runs strictly after the prior
|
|
23
|
+
* one by chaining onto this promise.
|
|
24
|
+
*/
|
|
25
|
+
private writeChain;
|
|
26
|
+
constructor(dir?: string);
|
|
27
|
+
private fileFor;
|
|
28
|
+
/** Serialize a disk operation for `ws` behind any in-flight write. */
|
|
29
|
+
private enqueue;
|
|
30
|
+
/** Re-hydrate every persisted transcript into memory. */
|
|
31
|
+
load(): Promise<void>;
|
|
32
|
+
/** Chronological transcript for a workspace (empty if none). */
|
|
33
|
+
entries(ws: string): ChatEntry[];
|
|
34
|
+
/** Append one entry to the workspace's running log (memory + disk). */
|
|
35
|
+
append(ws: string, entry: ChatEntry): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Stamp the decision onto the matching (most recent unresolved) permission
|
|
38
|
+
* entry and rewrite the file. No-op if the request isn't in the log.
|
|
39
|
+
*/
|
|
40
|
+
resolvePermission(ws: string, requestId: string, decision: "allow" | "deny"): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Stamp the user's answers onto the matching (most recent unanswered) question
|
|
43
|
+
* entry and rewrite the file, so a replay renders it resolved (collapsed).
|
|
44
|
+
* No-op if the request isn't in the log.
|
|
45
|
+
*/
|
|
46
|
+
resolveQuestion(ws: string, requestId: string, answers: Record<string, string | string[]>, response?: string): Promise<void>;
|
|
47
|
+
/** Drop a workspace's transcript (called on workspace delete). */
|
|
48
|
+
remove(ws: string): Promise<void>;
|
|
49
|
+
private rewrite;
|
|
50
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatLog — a durable, per-workspace running transcript.
|
|
3
|
+
*
|
|
4
|
+
* The daemon already broadcasts every prompt/response as an `event.*` frame,
|
|
5
|
+
* but those are ephemeral: a phone that reboots (or connects fresh) has missed
|
|
6
|
+
* them. ChatLog is the memory. Each workspace gets an append-only JSONL file
|
|
7
|
+
* under `~/.immediacy/chatlogs/<wsId>.jsonl`; one `ChatEntry` per line. New
|
|
8
|
+
* entries append (cheap); the only mutation is stamping a permission's
|
|
9
|
+
* `resolved` decision, which rewrites that workspace's file from memory.
|
|
10
|
+
*
|
|
11
|
+
* On startup `load()` reads the files back so history survives a daemon
|
|
12
|
+
* restart too, mirroring how `state.json` re-hydrates workspaces.
|
|
13
|
+
*/
|
|
14
|
+
import { appendFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { logger } from "./log.js";
|
|
18
|
+
const CHATLOG_DIR = process.env.CHATLOG_DIR ?? join(homedir(), ".immediacy", "chatlogs");
|
|
19
|
+
export class ChatLog {
|
|
20
|
+
dir;
|
|
21
|
+
mem = new Map();
|
|
22
|
+
/**
|
|
23
|
+
* Per-workspace write chain. `append` and `resolvePermission`→`rewrite` are
|
|
24
|
+
* fired-and-forgotten from many call sites; without serialization an
|
|
25
|
+
* appendFile can interleave with a full-file rewrite and corrupt or truncate
|
|
26
|
+
* the JSONL. Every disk write for a workspace runs strictly after the prior
|
|
27
|
+
* one by chaining onto this promise.
|
|
28
|
+
*/
|
|
29
|
+
writeChain = new Map();
|
|
30
|
+
constructor(dir = CHATLOG_DIR) {
|
|
31
|
+
this.dir = dir;
|
|
32
|
+
}
|
|
33
|
+
fileFor(ws) {
|
|
34
|
+
return join(this.dir, `${safeId(ws)}.jsonl`);
|
|
35
|
+
}
|
|
36
|
+
/** Serialize a disk operation for `ws` behind any in-flight write. */
|
|
37
|
+
enqueue(ws, op) {
|
|
38
|
+
const next = (this.writeChain.get(ws) ?? Promise.resolve())
|
|
39
|
+
.catch(() => { }) // a failed prior write must not poison the chain
|
|
40
|
+
.then(op);
|
|
41
|
+
this.writeChain.set(ws, next);
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
44
|
+
/** Re-hydrate every persisted transcript into memory. */
|
|
45
|
+
async load() {
|
|
46
|
+
let names;
|
|
47
|
+
try {
|
|
48
|
+
names = await readdir(this.dir);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return; // no logs yet
|
|
52
|
+
}
|
|
53
|
+
for (const name of names) {
|
|
54
|
+
if (!name.endsWith(".jsonl"))
|
|
55
|
+
continue;
|
|
56
|
+
const ws = name.slice(0, -".jsonl".length);
|
|
57
|
+
try {
|
|
58
|
+
const raw = await readFile(join(this.dir, name), "utf8");
|
|
59
|
+
const entries = [];
|
|
60
|
+
for (const line of raw.split("\n")) {
|
|
61
|
+
if (line.trim().length === 0)
|
|
62
|
+
continue;
|
|
63
|
+
try {
|
|
64
|
+
entries.push(JSON.parse(line));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Skip only the bad line (e.g. a half-written final record after a
|
|
68
|
+
// hard kill) — keep all the valid history rather than dropping the
|
|
69
|
+
// whole workspace transcript for one corrupt byte.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.mem.set(ws, entries);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
/* unreadable file — skip this workspace rather than crash the daemon */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Chronological transcript for a workspace (empty if none). */
|
|
80
|
+
entries(ws) {
|
|
81
|
+
return this.mem.get(ws) ?? [];
|
|
82
|
+
}
|
|
83
|
+
/** Append one entry to the workspace's running log (memory + disk). */
|
|
84
|
+
async append(ws, entry) {
|
|
85
|
+
let entries = this.mem.get(ws);
|
|
86
|
+
if (!entries)
|
|
87
|
+
this.mem.set(ws, (entries = []));
|
|
88
|
+
entries.push(entry);
|
|
89
|
+
return this.enqueue(ws, async () => {
|
|
90
|
+
try {
|
|
91
|
+
await mkdir(this.dir, { recursive: true });
|
|
92
|
+
await appendFile(this.fileFor(ws), JSON.stringify(entry) + "\n", "utf8");
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
// Logging is best-effort: never let a disk hiccup break the live flow.
|
|
96
|
+
logger.error("error", "chatlog append failed", {
|
|
97
|
+
ws,
|
|
98
|
+
data: { message: err instanceof Error ? err.message : String(err) },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Stamp the decision onto the matching (most recent unresolved) permission
|
|
105
|
+
* entry and rewrite the file. No-op if the request isn't in the log.
|
|
106
|
+
*/
|
|
107
|
+
async resolvePermission(ws, requestId, decision) {
|
|
108
|
+
const entries = this.mem.get(ws);
|
|
109
|
+
if (!entries)
|
|
110
|
+
return;
|
|
111
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
112
|
+
const e = entries[i];
|
|
113
|
+
if (e.role === "permission" && e.requestId === requestId && !e.resolved) {
|
|
114
|
+
e.resolved = decision;
|
|
115
|
+
return this.rewrite(ws);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Stamp the user's answers onto the matching (most recent unanswered) question
|
|
121
|
+
* entry and rewrite the file, so a replay renders it resolved (collapsed).
|
|
122
|
+
* No-op if the request isn't in the log.
|
|
123
|
+
*/
|
|
124
|
+
async resolveQuestion(ws, requestId, answers, response) {
|
|
125
|
+
const entries = this.mem.get(ws);
|
|
126
|
+
if (!entries)
|
|
127
|
+
return;
|
|
128
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
129
|
+
const e = entries[i];
|
|
130
|
+
if (e.role === "question" && e.requestId === requestId && !e.answers && !e.response) {
|
|
131
|
+
e.answers = answers;
|
|
132
|
+
if (response)
|
|
133
|
+
e.response = response;
|
|
134
|
+
return this.rewrite(ws);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Drop a workspace's transcript (called on workspace delete). */
|
|
139
|
+
async remove(ws) {
|
|
140
|
+
this.mem.delete(ws);
|
|
141
|
+
return this.enqueue(ws, async () => {
|
|
142
|
+
try {
|
|
143
|
+
await rm(this.fileFor(ws), { force: true });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
/* best-effort */
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
rewrite(ws) {
|
|
151
|
+
return this.enqueue(ws, async () => {
|
|
152
|
+
const entries = this.mem.get(ws) ?? [];
|
|
153
|
+
try {
|
|
154
|
+
await mkdir(this.dir, { recursive: true });
|
|
155
|
+
const body = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
156
|
+
await writeFile(this.fileFor(ws), body, "utf8");
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
logger.error("error", "chatlog rewrite failed", {
|
|
160
|
+
ws,
|
|
161
|
+
data: { message: err instanceof Error ? err.message : String(err) },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Guard the on-disk filename against path traversal. Workspace ids are UUIDs in
|
|
169
|
+
* normal operation, but ids reach `fileFor` straight off the wire (`chat.history`)
|
|
170
|
+
* and via a caller-overridable `CHATLOG_DIR`; a `ws` of `../../foo` must never
|
|
171
|
+
* let `remove`/`append` touch a file outside the chatlog dir.
|
|
172
|
+
*/
|
|
173
|
+
function safeId(ws) {
|
|
174
|
+
if (!/^[A-Za-z0-9._-]+$/.test(ws) || ws === "." || ws === "..") {
|
|
175
|
+
throw new Error(`unsafe workspace id: ${JSON.stringify(ws)}`);
|
|
176
|
+
}
|
|
177
|
+
return ws;
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=chatlog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chatlog.js","sourceRoot":"","sources":["../../src/chatlog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AACvF,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAElC,MAAM,WAAW,GACf,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,IAAI,CAAC,OAAO,EAAE,EAAE,YAAY,EAAE,UAAU,CAAC,CAAC;AAEvE,MAAM,OAAO,OAAO;IAWE;IAVZ,GAAG,GAAG,IAAI,GAAG,EAAuB,CAAC;IAC7C;;;;;;OAMG;IACK,UAAU,GAAG,IAAI,GAAG,EAAyB,CAAC;IAEtD,YAAoB,MAAc,WAAW;QAAzB,QAAG,GAAH,GAAG,CAAsB;IAAG,CAAC;IAEzC,OAAO,CAAC,EAAU;QACxB,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC,QAAQ,CAAC,CAAC;IAC/C,CAAC;IAED,sEAAsE;IAC9D,OAAO,CAAC,EAAU,EAAE,EAAuB;QACjD,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;aACxD,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC,iDAAiD;aACjE,IAAI,CAAC,EAAE,CAAC,CAAC;QACZ,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;QAC9B,OAAO,IAAI,CAAC;IACd,CAAC;IAED,yDAAyD;IACzD,KAAK,CAAC,IAAI;QACR,IAAI,KAAe,CAAC;QACpB,IAAI,CAAC;YACH,KAAK,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAClC,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,CAAC,cAAc;QACxB,CAAC;QACD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC;gBAAE,SAAS;YACvC,MAAM,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;YAC3C,IAAI,CAAC;gBACH,MAAM,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC;gBACzD,MAAM,OAAO,GAAgB,EAAE,CAAC;gBAChC,KAAK,MAAM,IAAI,IAAI,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;oBACnC,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC;wBAAE,SAAS;oBACvC,IAAI,CAAC;wBACH,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAc,CAAC,CAAC;oBAC9C,CAAC;oBAAC,MAAM,CAAC;wBACP,mEAAmE;wBACnE,mEAAmE;wBACnE,mDAAmD;oBACrD,CAAC;gBACH,CAAC;gBACD,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,OAAO,CAAC,CAAC;YAC5B,CAAC;YAAC,MAAM,CAAC;gBACP,wEAAwE;YAC1E,CAAC;QACH,CAAC;IACH,CAAC;IAED,gEAAgE;IAChE,OAAO,CAAC,EAAU;QAChB,OAAO,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC;IAChC,CAAC;IAED,uEAAuE;IACvE,KAAK,CAAC,MAAM,CAAC,EAAU,EAAE,KAAgB;QACvC,IAAI,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAC/B,IAAI,CAAC,OAAO;YAAE,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC,OAAO,GAAG,EAAE,CAAC,CAAC,CAAC;QAC/C,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACpB,OAAO,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,KAAK,IAAI,EAAE;YACjC,IAAI,CAAC;gBACH,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;gBAC3C,MAAM,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,GAAG,IAAI,EAAE,MAAM,CAAC,CAAC;YAC3E,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,uEAAuE;gBACvE,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,uBAAuB,EAAE;oBAC7C,EAAE;oBACF,IAAI,EAAE,EAAE,OAAO,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE;iBACpE,CAAC,CAAC;YACL,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAED;;;OAGG;IACH,KAAK,CAAC,iBAAiB,CACrB,EAAU,EACV,SAAiB,EACjB,QAA0B;QAE1B,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACjC,IAAI,CAAC,OAAO;YAAE,OAAO;QACrB,KAAK,IAAI,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;YAC7C,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAE,CAAC;YACtB,IAAI,CAAC,CAAC,IAAI,KAAK,YAAY,IAAI,CAAC,CAAC,SAAS,KAAK,SAAS,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;gBACxE,CAAC,CAAC,QAAQ,GAAG,QAAQ,CAAC;gBACtB,OAAO,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;YAC1B,CAAC;QACH,CAAC;IACH,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,eAAe,CACnB,EAAU,EACV,SAAiB,EACjB,OAA0C,EAC1C,QAAiB;QAEjB,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QACjC,IAAI,CAAC,OAAO;YAAE,OAAO;QACrB,KAAK,IAAI,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;YAC7C,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAE,CAAC;YACtB,IAAI,CAAC,CAAC,IAAI,KAAK,UAAU,IAAI,CAAC,CAAC,SAAS,KAAK,SAAS,IAAI,CAAC,CAAC,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;gBACpF,CAAC,CAAC,OAAO,GAAG,OAAO,CAAC;gBACpB,IAAI,QAAQ;oBAAE,CAAC,CAAC,QAAQ,GAAG,QAAQ,CAAC;gBACpC,OAAO,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;YAC1B,CAAC;QACH,CAAC;IACH,CAAC;IAED,kEAAkE;IAClE,KAAK,CAAC,MAAM,CAAC,EAAU;QACrB,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACpB,OAAO,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,KAAK,IAAI,EAAE;YACjC,IAAI,CAAC;gBACH,MAAM,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;YAC9C,CAAC;YAAC,MAAM,CAAC;gBACP,iBAAiB;YACnB,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAEO,OAAO,CAAC,EAAU;QACxB,OAAO,IAAI,CAAC,OAAO,CAAC,EAAE,EAAE,KAAK,IAAI,EAAE;YACjC,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,CAAC;YACvC,IAAI,CAAC;gBACH,MAAM,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;gBAC3C,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;gBACrE,MAAM,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC;YAClD,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,MAAM,CAAC,KAAK,CAAC,OAAO,EAAE,wBAAwB,EAAE;oBAC9C,EAAE;oBACF,IAAI,EAAE,EAAE,OAAO,EAAE,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,EAAE;iBACpE,CAAC,CAAC;YACL,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;CACF;AAED;;;;;GAKG;AACH,SAAS,MAAM,CAAC,EAAU;IACxB,IAAI,CAAC,mBAAmB,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,KAAK,GAAG,IAAI,EAAE,KAAK,IAAI,EAAE,CAAC;QAC/D,MAAM,IAAI,KAAK,CAAC,wBAAwB,IAAI,CAAC,SAAS,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,EAAE,CAAC;AACZ,CAAC"}
|