apex-code 0.1.0 → 0.2.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/CHANGELOG.md +29 -0
- package/README.md +4 -5
- package/dist/core/permissions/gate.d.ts.map +1 -1
- package/dist/core/permissions/gate.js +5 -2
- package/dist/core/permissions/gate.js.map +1 -1
- package/dist/core/permissions/modes.d.ts +1 -1
- package/dist/core/permissions/modes.d.ts.map +1 -1
- package/dist/core/permissions/modes.js +12 -2
- package/dist/core/permissions/modes.js.map +1 -1
- package/dist/core/permissions/protected-paths.d.ts +23 -0
- package/dist/core/permissions/protected-paths.d.ts.map +1 -0
- package/dist/core/permissions/protected-paths.js +41 -0
- package/dist/core/permissions/protected-paths.js.map +1 -0
- package/dist/core/removed-settings.d.ts +26 -0
- package/dist/core/removed-settings.d.ts.map +1 -0
- package/dist/core/removed-settings.js +40 -0
- package/dist/core/removed-settings.js.map +1 -0
- package/dist/core/settings-diagnostics.d.ts.map +1 -1
- package/dist/core/settings-diagnostics.js +16 -1
- package/dist/core/settings-diagnostics.js.map +1 -1
- package/dist/core/settings-manager.d.ts +2 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +4 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/tools/contract.d.ts +15 -0
- package/dist/core/tools/contract.d.ts.map +1 -1
- package/dist/core/tools/contract.js.map +1 -1
- package/dist/core/tools/path-permission.d.ts.map +1 -1
- package/dist/core/tools/path-permission.js +8 -1
- package/dist/core/tools/path-permission.js.map +1 -1
- package/dist/modes/interactive/components/assistant-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/assistant-message.js +10 -0
- package/dist/modes/interactive/components/assistant-message.js.map +1 -1
- package/dist/modes/interactive/components/message-spine.d.ts +14 -0
- package/dist/modes/interactive/components/message-spine.d.ts.map +1 -0
- package/dist/modes/interactive/components/message-spine.js +19 -0
- package/dist/modes/interactive/components/message-spine.js.map +1 -0
- package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/settings-selector.js +1 -1
- package/dist/modes/interactive/components/settings-selector.js.map +1 -1
- package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
- package/dist/modes/interactive/components/user-message.js +4 -1
- package/dist/modes/interactive/components/user-message.js.map +1 -1
- package/dist/utils/paths.d.ts +10 -0
- package/dist/utils/paths.d.ts.map +1 -1
- package/dist/utils/paths.js +34 -2
- package/dist/utils/paths.js.map +1 -1
- package/docs/containerization.md +8 -4
- package/docs/sdk.md +17 -24
- package/docs/security.md +7 -5
- package/docs/skills.md +4 -7
- package/npm-shrinkwrap.json +5 -5
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Apex Code changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-09-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Conversational messages carry an accent spine down their left edge, so user and assistant text share one visual axis. The spine occupies the existing padding column rather than sitting beside it, so a message still renders at exactly the width it was given.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **The `0.1.x` line is documented as stable.** `0.1.1` was published as a plain SemVer version, so npm's `latest` already pointed at it and a plain `npm install apex-code` already resolved it, while the README, `SECURITY.md`, `docs/user-guide.md`, and `docs/support.md` all still called the project pre-alpha with "no stable version published". A test pinned the stale sentence in place. The artifact was right and the labels were wrong, so the labels changed. Recorded in [ADR 0033](../../docs/adr/0033-stable-release-line.md). Security support now follows the latest non-deprecated `0.1.x` release. This is not a 1.0; the `0.x` major still signals that the shape of the product is moving, and what it commits to is that a removal ships with its migration stated rather than only a changelog line.
|
|
12
|
+
|
|
13
|
+
- **`acceptEdits` now stays inside the workspace.** The mode auto-allowed any edit-shaped tool call on capability alone, with no path in the decision at all. While the process boundary existed its mounts supplied the scope the mode's name implies, so the missing test cost nothing. `0.1.0` removed the mounts and the mode quietly became a standing grant over every path your account can write, including one that creates missing parent directories from the filesystem root. It now auto-allows an edit only when the target resolves inside the workspace, and asks for anything outside it. The test follows symlinks, so a link in a checkout that points at your home directory does not count as inside. An extension tool that declares `fs.write` and cannot say where it writes is no longer auto-allowed either; it asks. The selector and `README.md` said "plain file edits", which was true under the mounts and is now said precisely.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **A removed egress setting stopped restricting anything without saying so.** `0.1.0` removed `network.allowedHosts`, `network.allowDefaultHosts`, and `sandboxProfiles` and said to delete them. The removed CLI flags enforce that, because an unrecognized flag exits non-zero before a session starts. A settings file had no equivalent, since the loader keeps keys it does not recognize and reads none of them. An operator who had restricted egress in `0.0.6` upgraded, kept their file, and lost the restriction with nothing said, which is the one shape of removal that leaves someone less careful rather than merely out of date. Startup now warns once per scope, names every removed key in the file and the file's path, and says the keys no longer restrict anything. The keys stay ignored; only the silence is fixed.
|
|
18
|
+
|
|
19
|
+
- **The agent could read its own credential file with no decision.** `read`, `grep`, `ls`, and `find` default to allow and nothing checked what path they were given, so `~/.apex-code/auth.json`, which holds your provider keys in cleartext, was an ordinary tool call. Its `0600` mode protects it from other accounts, not from the session running as you. While the process boundary existed the home directory was hidden and that one file was deliberately mounted; `0.1.0` removed the boundary and left the default, so a repository that talks an agent into reading one file could take the key. Those four tools now require an explicit decision for that file, and for a directory that contains it, since the recursive ones reach it by naming a parent. No project, local, or user rule can pre-approve it and `bypassPermissions` does not lift it; a managed `policy` file still can, because that is the machine owner's. Sibling files in the agent directory are unaffected, so settings and session transcripts still read normally. A session working in your home directory will now ask once before scanning all of it.
|
|
20
|
+
|
|
21
|
+
## [0.1.1] - 2026-09-13
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **The shipped documentation contradicted the release.** `0.1.0` removed the OS sandbox and then published four pages under `docs/` that still described it. `docs/security.md` asserted a Bubblewrap and Seatbelt boundary and scoped reportable vulnerabilities against "a real bypass of the permission gate or OS sandbox". `docs/containerization.md`, the page that answers how to confine a session, opened by telling you that you already had a sandbox. `docs/sdk.md` documented a `sandbox` option whose `"required"` value used to refuse to construct a session without containment, and which no longer exists. `docs/skills.md` claimed every session runs inside the sandbox with global skill roots mounted back in read-only. A reader who trusted the security page would have believed a session was confined when nothing confined it, which is the one kind of stale documentation that can make someone less careful rather than merely misinformed. All four now describe the posture the code actually has.
|
|
26
|
+
- `packages/coding-agent/README.md` linked the containerization guide as `containerization.md` where the file is `docs/containerization.md`, so the page you are sent to for isolation was unreachable from the package README.
|
|
27
|
+
|
|
3
28
|
## [0.1.0] - 2026-09-13
|
|
4
29
|
|
|
5
30
|
### Fixed
|
|
@@ -9,6 +34,10 @@
|
|
|
9
34
|
- **An explicit agent directory did not isolate MCP config.** The user-scope `mcp.json` was always read from your home directory whatever `APEX_CODE_CODING_AGENT_DIR` said, unlike settings, credentials, and sessions, which all follow it. Running with a separate agent directory still picked up your everyday MCP servers. The lookup now follows the agent directory, so a custom one moves it too. For the default agent directory the path is unchanged.
|
|
10
35
|
- **Security.** A repository could grant itself permissions with no trust prompt. Project trust only ever gated `settings.json`, `extensions`, `skills`, `prompts`, `themes`, `SYSTEM.md`, and `APPEND_SYSTEM.md`, so a checkout supplying `.apex-code/permissions.json`, `.apex-code/permissions.local.json`, `.apex-code/agents/`, or a root `.mcp.json` was classified trusted and its rules, permission mode, agent definitions, or MCP servers loaded without a decision. Cloning such a repository and starting a session was the whole exploit. Those four resources are now gated, and every project resource path is resolved in one place so a loader cannot read a path the classifier does not check.
|
|
11
36
|
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- Conversational messages carry an accent spine down their left edge, so user and assistant text share one visual axis. The spine occupies the existing padding column rather than being added beside it, so a message still renders at exactly the width it was given.
|
|
40
|
+
|
|
12
41
|
### Changed
|
|
13
42
|
|
|
14
43
|
- Starting a session in a repository that contains a `.mcp.json`, a project or local permissions file, or a `.apex-code/agents/` directory now asks for a trust decision the first time. Until it is answered, those project resources do not load. A permissions file that grants nothing (`{}`) does not prompt, so a repository that ships an empty one is unaffected. This is the security fix above; the prompt is the point.
|
package/README.md
CHANGED
|
@@ -6,10 +6,9 @@ a broader tool surface, delegation, durable execution, evidence, and cost visibi
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
resolve it from the same npm registry:
|
|
9
|
+
`latest` names the newest stable release on the `0.1.x` line and a plain install resolves
|
|
10
|
+
it. It requires Node.js 22.19 or newer. It installs the same way with npm, pnpm, Yarn, or
|
|
11
|
+
Bun — all resolve it from the same npm registry:
|
|
13
12
|
|
|
14
13
|
```bash
|
|
15
14
|
npm install --global apex-code # npm
|
|
@@ -84,7 +83,7 @@ upstream vocabulary, not executable or product branding.
|
|
|
84
83
|
## Documentation
|
|
85
84
|
|
|
86
85
|
- [`docs/`](docs/) — CLI, extension, provider, theme, and integration reference
|
|
87
|
-
- [`containerization.md`](containerization.md) —
|
|
86
|
+
- [`docs/containerization.md`](docs/containerization.md) — how to confine a session, and the patterns for doing it
|
|
88
87
|
- [`CHANGELOG.md`](CHANGELOG.md) — current Apex Code changes and upstream history
|
|
89
88
|
- [Source repository](https://github.com/Fchery87/apex-code)
|
|
90
89
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../../../src/core/permissions/gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACxF,OAAO,EAA4C,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEnG,OAAO,KAAK,EAAqB,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE7E,OAAO,KAAK,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEtE,MAAM,WAAW,qBAAqB;IACrC,2IAA2I;IAC3I,WAAW,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,YAAY,GAAG,SAAS,CAAC;IAC5D,KAAK,EAAE,mBAAmB,CAAC;IAC3B,OAAO,EAAE,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IACxD;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,yFAAyF;IACzF,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAChC,qGAAqG;IACrG,YAAY,CAAC,EAAE,MAAM,mBAAmB,GAAG,SAAS,CAAC;CACrD;AAED,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAwCD,oHAAkH;AAClH,wBAAsB,gBAAgB,CACrC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,OAAO,EACf,OAAO,EAAE,qBAAqB,GAC5B,OAAO,CAAC,YAAY,CAAC,
|
|
1
|
+
{"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../../../src/core/permissions/gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACxF,OAAO,EAA4C,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAEnG,OAAO,KAAK,EAAqB,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAE7E,OAAO,KAAK,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEtE,MAAM,WAAW,qBAAqB;IACrC,2IAA2I;IAC3I,WAAW,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,YAAY,GAAG,SAAS,CAAC;IAC5D,KAAK,EAAE,mBAAmB,CAAC;IAC3B,OAAO,EAAE,MAAM,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IACxD;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,yFAAyF;IACzF,SAAS,CAAC,EAAE,mBAAmB,CAAC;IAChC,qGAAqG;IACrG,YAAY,CAAC,EAAE,MAAM,mBAAmB,GAAG,SAAS,CAAC;CACrD;AAED,MAAM,WAAW,YAAY;IAC5B,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CAChB;AAwCD,oHAAkH;AAClH,wBAAsB,gBAAgB,CACrC,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,OAAO,EACf,OAAO,EAAE,qBAAqB,GAC5B,OAAO,CAAC,YAAY,CAAC,CA6DvB;AAED,uFAAuF;AACvF,wBAAgB,oBAAoB,CACnC,OAAO,EAAE,qBAAqB,GAC5B,CAAC,OAAO,EAAE,qBAAqB,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAKrG","sourcesContent":["/**\n * The permission gate: composes rule resolution (rules.ts), mode overlay\n * (modes.ts), and interactive escalation (responder.ts) into a single decision,\n * and adapts that decision to the `beforeToolCall` seam `agent-core` already\n * exposes (packages/agent/src/agent-loop.ts). No new hook, no agent-core change.\n *\n * Tool execution has exactly one call site downstream of this hook\n * (`executePreparedToolCall`, fed only by the function that runs `beforeToolCall`),\n * so wiring this in once at session setup is sufficient for \"every registered tool\n * passes the gate\" to be true by construction — the universal-gate test (invariant\n * 2) verifies this property holds, it does not have to work to make it hold.\n */\n\nimport type { BeforeToolCallContext, BeforeToolCallResult } from \"apex-code-agent-core\";\nimport { type PermissionSpec, resolveToolContract, type ToolContract } from \"../tools/contract.ts\";\nimport { resolveWithMode } from \"./modes.ts\";\nimport type { PermissionPreview, PermissionResponder } from \"./responder.ts\";\nimport { resolvePermission } from \"./rules.ts\";\nimport type { PermissionMode, PermissionRuleStore } from \"./store.ts\";\n\nexport interface PermissionGateOptions {\n\t/** Resolve a tool's contract by name. Foreign tools with no contract get UNCLASSIFIED, never rejected or silently defaulted (ADR 0010). */\n\tgetContract: (toolName: string) => ToolContract | undefined;\n\tstore: PermissionRuleStore;\n\tgetMode: () => PermissionMode | Promise<PermissionMode>;\n\t/**\n\t * The `--permission-mode` value, when one was passed. Not used to decide\n\t * anything here — `getMode` already folds it in. It is carried so a UI that\n\t * offers to change the mode can tell the user their write is outranked\n\t * (permissions/startup.ts, resolveEffectiveModeWithOrigin).\n\t */\n\tflagMode?: PermissionMode;\n\t/** Absent in a non-interactive session: an `ask` resolution then fails closed (deny). */\n\tresponder?: PermissionResponder;\n\t/** Resolves a responder at call time because the interactive UI binds after session construction. */\n\tgetResponder?: () => PermissionResponder | undefined;\n}\n\nexport interface GateDecision {\n\tblock: boolean;\n\treason?: string;\n}\n\nfunction describeDecision(contract: ToolContract, toolName: string, ruleContent: string | undefined): string {\n\tif (ruleContent !== undefined) return contract.permission.describe(ruleContent);\n\treturn `${toolName} is not permitted by the current permission configuration.`;\n}\n\n/**\n * A denial, plus whatever the user said to do instead.\n *\n * The reason becomes the blocked tool result the model reads, so guidance is\n * bounded here rather than pasted whole: it is user-entered free text on a path\n * that reaches the transcript.\n */\nconst MAX_GUIDANCE_CHARS = 400;\n\n/**\n * Run the tool's preview producer, if it declares one, on the ask branch only.\n *\n * A producer reads a file to describe a change, so a call the user was never\n * asked about must not pay for it. A producer that throws degrades to a stated\n * reason rather than to silence: `readPreparedPath` throws precisely when the\n * target changed identity since authorization, which is the case a reader most\n * needs to see.\n */\nfunction producePreview(spec: PermissionSpec, params: unknown): PermissionPreview | undefined {\n\tif (!spec.previewCall) return undefined;\n\ttry {\n\t\treturn spec.previewCall(params as never);\n\t} catch (error) {\n\t\treturn { kind: \"unavailable\", reason: error instanceof Error ? error.message : String(error) };\n\t}\n}\n\nfunction describeDecline(toolName: string, guidance: string | undefined): string {\n\tconst trimmed = guidance?.trim().slice(0, MAX_GUIDANCE_CHARS);\n\tif (!trimmed) return `${toolName} was declined.`;\n\treturn `${toolName} was declined. The user asked for this instead. ${trimmed}`;\n}\n\n/** Pure decision function, independent of the beforeToolCall adapter shape below — the part under direct test. */\nexport async function evaluateToolCall(\n\ttoolName: string,\n\tparams: unknown,\n\toptions: PermissionGateOptions,\n): Promise<GateDecision> {\n\tconst contract = resolveToolContract(options.getContract, toolName);\n\tconst spec = contract.permission;\n\tif (spec.prepareCall) spec.prepareCall(params as never);\n\tconst snapshot = await options.store.snapshot();\n\tif (snapshot.errors.length > 0) {\n\t\tconst sources = [...new Set(snapshot.errors.map((entry) => entry.source))].join(\", \");\n\t\treturn {\n\t\t\tblock: true,\n\t\t\treason: `Permission configuration could not be loaded (${sources}); refusing to run ${toolName}.`,\n\t\t};\n\t}\n\tconst protectedTarget = spec.protectedTarget?.(params as never);\n\tconst ruleResolution = resolvePermission(snapshot.rules, toolName, spec, params as never);\n\tconst resolution = resolveWithMode(\n\t\tawait options.getMode(),\n\t\truleResolution,\n\t\tcontract.capabilities,\n\t\tspec.withinWorkspace?.(params as never),\n\t\tprotectedTarget,\n\t);\n\n\tif (resolution.behavior === \"allow\") return { block: false };\n\n\tif (resolution.behavior === \"deny\") {\n\t\treturn { block: true, reason: describeDecision(contract, toolName, resolution.rule?.ruleContent) };\n\t}\n\n\t// ask\n\tconst responder = options.getResponder?.() ?? options.responder;\n\tif (!responder) {\n\t\treturn {\n\t\t\tblock: true,\n\t\t\treason:\n\t\t\t\tprotectedTarget !== undefined\n\t\t\t\t\t? `${toolName} was refused: ${protectedTarget}. No rule can grant this path; supply the contents deliberately if you need them.`\n\t\t\t\t\t: `${toolName} requires approval, and no responder is available in this session.`,\n\t\t};\n\t}\n\tconst ruleForCall = spec.ruleForCall(params as never);\n\tconst answer = await responder.ask({\n\t\tpreview: producePreview(spec, params),\n\t\ttoolName,\n\t\tdescription: ruleForCall !== null ? spec.describe(ruleForCall) : `Run ${toolName}`,\n\t\t// Only offer a session grant the persist branch below would actually write.\n\t\tsessionScope: ruleForCall !== null ? { description: spec.describe(ruleForCall) } : undefined,\n\t});\n\t// `persist` means the same thing on both branches: write the rule that decides\n\t// this exact call the way the user just decided it. Reading it only on the allow\n\t// branch is what let \"Reject always\" refuse once and then ask again.\n\tif (answer.persist && ruleForCall !== null) {\n\t\tawait options.store.apply({\n\t\t\ttype: \"addRules\",\n\t\t\tdestination: \"session\",\n\t\t\trules: [{ toolName, behavior: answer.allow ? \"allow\" : \"deny\", ruleContent: ruleForCall }],\n\t\t});\n\t}\n\tif (!answer.allow) {\n\t\treturn { block: true, reason: describeDecline(toolName, answer.guidance) };\n\t}\n\treturn { block: false };\n}\n\n/** Adapts evaluateToolCall() to the beforeToolCall seam agent-core already exposes. */\nexport function createPermissionGate(\n\toptions: PermissionGateOptions,\n): (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined> {\n\treturn async ({ toolCall, args }) => {\n\t\tconst decision = await evaluateToolCall(toolCall.name, args, options);\n\t\treturn decision.block ? { block: true, reason: decision.reason } : undefined;\n\t};\n}\n"]}
|
|
@@ -65,8 +65,9 @@ export async function evaluateToolCall(toolName, params, options) {
|
|
|
65
65
|
reason: `Permission configuration could not be loaded (${sources}); refusing to run ${toolName}.`,
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
|
+
const protectedTarget = spec.protectedTarget?.(params);
|
|
68
69
|
const ruleResolution = resolvePermission(snapshot.rules, toolName, spec, params);
|
|
69
|
-
const resolution = resolveWithMode(await options.getMode(), ruleResolution, contract.capabilities);
|
|
70
|
+
const resolution = resolveWithMode(await options.getMode(), ruleResolution, contract.capabilities, spec.withinWorkspace?.(params), protectedTarget);
|
|
70
71
|
if (resolution.behavior === "allow")
|
|
71
72
|
return { block: false };
|
|
72
73
|
if (resolution.behavior === "deny") {
|
|
@@ -77,7 +78,9 @@ export async function evaluateToolCall(toolName, params, options) {
|
|
|
77
78
|
if (!responder) {
|
|
78
79
|
return {
|
|
79
80
|
block: true,
|
|
80
|
-
reason:
|
|
81
|
+
reason: protectedTarget !== undefined
|
|
82
|
+
? `${toolName} was refused: ${protectedTarget}. No rule can grant this path; supply the contents deliberately if you need them.`
|
|
83
|
+
: `${toolName} requires approval, and no responder is available in this session.`,
|
|
81
84
|
};
|
|
82
85
|
}
|
|
83
86
|
const ruleForCall = spec.ruleForCall(params);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate.js","sourceRoot":"","sources":["../../../src/core/permissions/gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,EAAuB,mBAAmB,EAAqB,MAAM,sBAAsB,CAAC;AACnG,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAE7C,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AA0B/C,SAAS,gBAAgB,CAAC,QAAsB,EAAE,QAAgB,EAAE,WAA+B,EAAU;IAC5G,IAAI,WAAW,KAAK,SAAS;QAAE,OAAO,QAAQ,CAAC,UAAU,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;IAChF,OAAO,GAAG,QAAQ,4DAA4D,CAAC;AAAA,CAC/E;AAED;;;;;;GAMG;AACH,MAAM,kBAAkB,GAAG,GAAG,CAAC;AAE/B;;;;;;;;GAQG;AACH,SAAS,cAAc,CAAC,IAAoB,EAAE,MAAe,EAAiC;IAC7F,IAAI,CAAC,IAAI,CAAC,WAAW;QAAE,OAAO,SAAS,CAAC;IACxC,IAAI,CAAC;QACJ,OAAO,IAAI,CAAC,WAAW,CAAC,MAAe,CAAC,CAAC;IAC1C,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,EAAE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IAChG,CAAC;AAAA,CACD;AAED,SAAS,eAAe,CAAC,QAAgB,EAAE,QAA4B,EAAU;IAChF,MAAM,OAAO,GAAG,QAAQ,EAAE,IAAI,EAAE,CAAC,KAAK,CAAC,CAAC,EAAE,kBAAkB,CAAC,CAAC;IAC9D,IAAI,CAAC,OAAO;QAAE,OAAO,GAAG,QAAQ,gBAAgB,CAAC;IACjD,OAAO,GAAG,QAAQ,mDAAmD,OAAO,EAAE,CAAC;AAAA,CAC/E;AAED,oHAAkH;AAClH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CACrC,QAAgB,EAChB,MAAe,EACf,OAA8B,EACN;IACxB,MAAM,QAAQ,GAAG,mBAAmB,CAAC,OAAO,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC;IACpE,MAAM,IAAI,GAAG,QAAQ,CAAC,UAAU,CAAC;IACjC,IAAI,IAAI,CAAC,WAAW;QAAE,IAAI,CAAC,WAAW,CAAC,MAAe,CAAC,CAAC;IACxD,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;IAChD,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAChC,MAAM,OAAO,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACtF,OAAO;YACN,KAAK,EAAE,IAAI;YACX,MAAM,EAAE,iDAAiD,OAAO,sBAAsB,QAAQ,GAAG;SACjG,CAAC;IACH,CAAC;IACD,MAAM,cAAc,GAAG,iBAAiB,CAAC,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAe,CAAC,CAAC;IAC1F,MAAM,UAAU,GAAG,eAAe,CAAC,MAAM,OAAO,CAAC,OAAO,EAAE,EAAE,cAAc,EAAE,QAAQ,CAAC,YAAY,CAAC,CAAC;IAEnG,IAAI,UAAU,CAAC,QAAQ,KAAK,OAAO;QAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;IAE7D,IAAI,UAAU,CAAC,QAAQ,KAAK,MAAM,EAAE,CAAC;QACpC,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,EAAE,UAAU,CAAC,IAAI,EAAE,WAAW,CAAC,EAAE,CAAC;IACpG,CAAC;IAED,MAAM;IACN,MAAM,SAAS,GAAG,OAAO,CAAC,YAAY,EAAE,EAAE,IAAI,OAAO,CAAC,SAAS,CAAC;IAChE,IAAI,CAAC,SAAS,EAAE,CAAC;QAChB,OAAO;YACN,KAAK,EAAE,IAAI;YACX,MAAM,EAAE,GAAG,QAAQ,oEAAoE;SACvF,CAAC;IACH,CAAC;IACD,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,CAAC,MAAe,CAAC,CAAC;IACtD,MAAM,MAAM,GAAG,MAAM,SAAS,CAAC,GAAG,CAAC;QAClC,OAAO,EAAE,cAAc,CAAC,IAAI,EAAE,MAAM,CAAC;QACrC,QAAQ;QACR,WAAW,EAAE,WAAW,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,OAAO,QAAQ,EAAE;QAClF,4EAA4E;QAC5E,YAAY,EAAE,WAAW,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,CAAC,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS;KAC5F,CAAC,CAAC;IACH,+EAA+E;IAC/E,iFAAiF;IACjF,qEAAqE;IACrE,IAAI,MAAM,CAAC,OAAO,IAAI,WAAW,KAAK,IAAI,EAAE,CAAC;QAC5C,MAAM,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC;YACzB,IAAI,EAAE,UAAU;YAChB,WAAW,EAAE,SAAS;YACtB,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,EAAE,WAAW,EAAE,WAAW,EAAE,CAAC;SAC1F,CAAC,CAAC;IACJ,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACnB,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,eAAe,CAAC,QAAQ,EAAE,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;IAC5E,CAAC;IACD,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC;AAAA,CACxB;AAED,uFAAuF;AACvF,MAAM,UAAU,oBAAoB,CACnC,OAA8B,EACwE;IACtG,OAAO,KAAK,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC;QACpC,MAAM,QAAQ,GAAG,MAAM,gBAAgB,CAAC,QAAQ,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;QACtE,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;IAAA,CAC7E,CAAC;AAAA,CACF","sourcesContent":["/**\n * The permission gate: composes rule resolution (rules.ts), mode overlay\n * (modes.ts), and interactive escalation (responder.ts) into a single decision,\n * and adapts that decision to the `beforeToolCall` seam `agent-core` already\n * exposes (packages/agent/src/agent-loop.ts). No new hook, no agent-core change.\n *\n * Tool execution has exactly one call site downstream of this hook\n * (`executePreparedToolCall`, fed only by the function that runs `beforeToolCall`),\n * so wiring this in once at session setup is sufficient for \"every registered tool\n * passes the gate\" to be true by construction — the universal-gate test (invariant\n * 2) verifies this property holds, it does not have to work to make it hold.\n */\n\nimport type { BeforeToolCallContext, BeforeToolCallResult } from \"apex-code-agent-core\";\nimport { type PermissionSpec, resolveToolContract, type ToolContract } from \"../tools/contract.ts\";\nimport { resolveWithMode } from \"./modes.ts\";\nimport type { PermissionPreview, PermissionResponder } from \"./responder.ts\";\nimport { resolvePermission } from \"./rules.ts\";\nimport type { PermissionMode, PermissionRuleStore } from \"./store.ts\";\n\nexport interface PermissionGateOptions {\n\t/** Resolve a tool's contract by name. Foreign tools with no contract get UNCLASSIFIED, never rejected or silently defaulted (ADR 0010). */\n\tgetContract: (toolName: string) => ToolContract | undefined;\n\tstore: PermissionRuleStore;\n\tgetMode: () => PermissionMode | Promise<PermissionMode>;\n\t/**\n\t * The `--permission-mode` value, when one was passed. Not used to decide\n\t * anything here — `getMode` already folds it in. It is carried so a UI that\n\t * offers to change the mode can tell the user their write is outranked\n\t * (permissions/startup.ts, resolveEffectiveModeWithOrigin).\n\t */\n\tflagMode?: PermissionMode;\n\t/** Absent in a non-interactive session: an `ask` resolution then fails closed (deny). */\n\tresponder?: PermissionResponder;\n\t/** Resolves a responder at call time because the interactive UI binds after session construction. */\n\tgetResponder?: () => PermissionResponder | undefined;\n}\n\nexport interface GateDecision {\n\tblock: boolean;\n\treason?: string;\n}\n\nfunction describeDecision(contract: ToolContract, toolName: string, ruleContent: string | undefined): string {\n\tif (ruleContent !== undefined) return contract.permission.describe(ruleContent);\n\treturn `${toolName} is not permitted by the current permission configuration.`;\n}\n\n/**\n * A denial, plus whatever the user said to do instead.\n *\n * The reason becomes the blocked tool result the model reads, so guidance is\n * bounded here rather than pasted whole: it is user-entered free text on a path\n * that reaches the transcript.\n */\nconst MAX_GUIDANCE_CHARS = 400;\n\n/**\n * Run the tool's preview producer, if it declares one, on the ask branch only.\n *\n * A producer reads a file to describe a change, so a call the user was never\n * asked about must not pay for it. A producer that throws degrades to a stated\n * reason rather than to silence: `readPreparedPath` throws precisely when the\n * target changed identity since authorization, which is the case a reader most\n * needs to see.\n */\nfunction producePreview(spec: PermissionSpec, params: unknown): PermissionPreview | undefined {\n\tif (!spec.previewCall) return undefined;\n\ttry {\n\t\treturn spec.previewCall(params as never);\n\t} catch (error) {\n\t\treturn { kind: \"unavailable\", reason: error instanceof Error ? error.message : String(error) };\n\t}\n}\n\nfunction describeDecline(toolName: string, guidance: string | undefined): string {\n\tconst trimmed = guidance?.trim().slice(0, MAX_GUIDANCE_CHARS);\n\tif (!trimmed) return `${toolName} was declined.`;\n\treturn `${toolName} was declined. The user asked for this instead. ${trimmed}`;\n}\n\n/** Pure decision function, independent of the beforeToolCall adapter shape below — the part under direct test. */\nexport async function evaluateToolCall(\n\ttoolName: string,\n\tparams: unknown,\n\toptions: PermissionGateOptions,\n): Promise<GateDecision> {\n\tconst contract = resolveToolContract(options.getContract, toolName);\n\tconst spec = contract.permission;\n\tif (spec.prepareCall) spec.prepareCall(params as never);\n\tconst snapshot = await options.store.snapshot();\n\tif (snapshot.errors.length > 0) {\n\t\tconst sources = [...new Set(snapshot.errors.map((entry) => entry.source))].join(\", \");\n\t\treturn {\n\t\t\tblock: true,\n\t\t\treason: `Permission configuration could not be loaded (${sources}); refusing to run ${toolName}.`,\n\t\t};\n\t}\n\tconst ruleResolution = resolvePermission(snapshot.rules, toolName, spec, params as never);\n\tconst resolution = resolveWithMode(await options.getMode(), ruleResolution, contract.capabilities);\n\n\tif (resolution.behavior === \"allow\") return { block: false };\n\n\tif (resolution.behavior === \"deny\") {\n\t\treturn { block: true, reason: describeDecision(contract, toolName, resolution.rule?.ruleContent) };\n\t}\n\n\t// ask\n\tconst responder = options.getResponder?.() ?? options.responder;\n\tif (!responder) {\n\t\treturn {\n\t\t\tblock: true,\n\t\t\treason: `${toolName} requires approval, and no responder is available in this session.`,\n\t\t};\n\t}\n\tconst ruleForCall = spec.ruleForCall(params as never);\n\tconst answer = await responder.ask({\n\t\tpreview: producePreview(spec, params),\n\t\ttoolName,\n\t\tdescription: ruleForCall !== null ? spec.describe(ruleForCall) : `Run ${toolName}`,\n\t\t// Only offer a session grant the persist branch below would actually write.\n\t\tsessionScope: ruleForCall !== null ? { description: spec.describe(ruleForCall) } : undefined,\n\t});\n\t// `persist` means the same thing on both branches: write the rule that decides\n\t// this exact call the way the user just decided it. Reading it only on the allow\n\t// branch is what let \"Reject always\" refuse once and then ask again.\n\tif (answer.persist && ruleForCall !== null) {\n\t\tawait options.store.apply({\n\t\t\ttype: \"addRules\",\n\t\t\tdestination: \"session\",\n\t\t\trules: [{ toolName, behavior: answer.allow ? \"allow\" : \"deny\", ruleContent: ruleForCall }],\n\t\t});\n\t}\n\tif (!answer.allow) {\n\t\treturn { block: true, reason: describeDecline(toolName, answer.guidance) };\n\t}\n\treturn { block: false };\n}\n\n/** Adapts evaluateToolCall() to the beforeToolCall seam agent-core already exposes. */\nexport function createPermissionGate(\n\toptions: PermissionGateOptions,\n): (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined> {\n\treturn async ({ toolCall, args }) => {\n\t\tconst decision = await evaluateToolCall(toolCall.name, args, options);\n\t\treturn decision.block ? { block: true, reason: decision.reason } : undefined;\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"gate.js","sourceRoot":"","sources":["../../../src/core/permissions/gate.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The permission gate: composes rule resolution (rules.ts), mode overlay\n * (modes.ts), and interactive escalation (responder.ts) into a single decision,\n * and adapts that decision to the `beforeToolCall` seam `agent-core` already\n * exposes (packages/agent/src/agent-loop.ts). No new hook, no agent-core change.\n *\n * Tool execution has exactly one call site downstream of this hook\n * (`executePreparedToolCall`, fed only by the function that runs `beforeToolCall`),\n * so wiring this in once at session setup is sufficient for \"every registered tool\n * passes the gate\" to be true by construction — the universal-gate test (invariant\n * 2) verifies this property holds, it does not have to work to make it hold.\n */\n\nimport type { BeforeToolCallContext, BeforeToolCallResult } from \"apex-code-agent-core\";\nimport { type PermissionSpec, resolveToolContract, type ToolContract } from \"../tools/contract.ts\";\nimport { resolveWithMode } from \"./modes.ts\";\nimport type { PermissionPreview, PermissionResponder } from \"./responder.ts\";\nimport { resolvePermission } from \"./rules.ts\";\nimport type { PermissionMode, PermissionRuleStore } from \"./store.ts\";\n\nexport interface PermissionGateOptions {\n\t/** Resolve a tool's contract by name. Foreign tools with no contract get UNCLASSIFIED, never rejected or silently defaulted (ADR 0010). */\n\tgetContract: (toolName: string) => ToolContract | undefined;\n\tstore: PermissionRuleStore;\n\tgetMode: () => PermissionMode | Promise<PermissionMode>;\n\t/**\n\t * The `--permission-mode` value, when one was passed. Not used to decide\n\t * anything here — `getMode` already folds it in. It is carried so a UI that\n\t * offers to change the mode can tell the user their write is outranked\n\t * (permissions/startup.ts, resolveEffectiveModeWithOrigin).\n\t */\n\tflagMode?: PermissionMode;\n\t/** Absent in a non-interactive session: an `ask` resolution then fails closed (deny). */\n\tresponder?: PermissionResponder;\n\t/** Resolves a responder at call time because the interactive UI binds after session construction. */\n\tgetResponder?: () => PermissionResponder | undefined;\n}\n\nexport interface GateDecision {\n\tblock: boolean;\n\treason?: string;\n}\n\nfunction describeDecision(contract: ToolContract, toolName: string, ruleContent: string | undefined): string {\n\tif (ruleContent !== undefined) return contract.permission.describe(ruleContent);\n\treturn `${toolName} is not permitted by the current permission configuration.`;\n}\n\n/**\n * A denial, plus whatever the user said to do instead.\n *\n * The reason becomes the blocked tool result the model reads, so guidance is\n * bounded here rather than pasted whole: it is user-entered free text on a path\n * that reaches the transcript.\n */\nconst MAX_GUIDANCE_CHARS = 400;\n\n/**\n * Run the tool's preview producer, if it declares one, on the ask branch only.\n *\n * A producer reads a file to describe a change, so a call the user was never\n * asked about must not pay for it. A producer that throws degrades to a stated\n * reason rather than to silence: `readPreparedPath` throws precisely when the\n * target changed identity since authorization, which is the case a reader most\n * needs to see.\n */\nfunction producePreview(spec: PermissionSpec, params: unknown): PermissionPreview | undefined {\n\tif (!spec.previewCall) return undefined;\n\ttry {\n\t\treturn spec.previewCall(params as never);\n\t} catch (error) {\n\t\treturn { kind: \"unavailable\", reason: error instanceof Error ? error.message : String(error) };\n\t}\n}\n\nfunction describeDecline(toolName: string, guidance: string | undefined): string {\n\tconst trimmed = guidance?.trim().slice(0, MAX_GUIDANCE_CHARS);\n\tif (!trimmed) return `${toolName} was declined.`;\n\treturn `${toolName} was declined. The user asked for this instead. ${trimmed}`;\n}\n\n/** Pure decision function, independent of the beforeToolCall adapter shape below — the part under direct test. */\nexport async function evaluateToolCall(\n\ttoolName: string,\n\tparams: unknown,\n\toptions: PermissionGateOptions,\n): Promise<GateDecision> {\n\tconst contract = resolveToolContract(options.getContract, toolName);\n\tconst spec = contract.permission;\n\tif (spec.prepareCall) spec.prepareCall(params as never);\n\tconst snapshot = await options.store.snapshot();\n\tif (snapshot.errors.length > 0) {\n\t\tconst sources = [...new Set(snapshot.errors.map((entry) => entry.source))].join(\", \");\n\t\treturn {\n\t\t\tblock: true,\n\t\t\treason: `Permission configuration could not be loaded (${sources}); refusing to run ${toolName}.`,\n\t\t};\n\t}\n\tconst protectedTarget = spec.protectedTarget?.(params as never);\n\tconst ruleResolution = resolvePermission(snapshot.rules, toolName, spec, params as never);\n\tconst resolution = resolveWithMode(\n\t\tawait options.getMode(),\n\t\truleResolution,\n\t\tcontract.capabilities,\n\t\tspec.withinWorkspace?.(params as never),\n\t\tprotectedTarget,\n\t);\n\n\tif (resolution.behavior === \"allow\") return { block: false };\n\n\tif (resolution.behavior === \"deny\") {\n\t\treturn { block: true, reason: describeDecision(contract, toolName, resolution.rule?.ruleContent) };\n\t}\n\n\t// ask\n\tconst responder = options.getResponder?.() ?? options.responder;\n\tif (!responder) {\n\t\treturn {\n\t\t\tblock: true,\n\t\t\treason:\n\t\t\t\tprotectedTarget !== undefined\n\t\t\t\t\t? `${toolName} was refused: ${protectedTarget}. No rule can grant this path; supply the contents deliberately if you need them.`\n\t\t\t\t\t: `${toolName} requires approval, and no responder is available in this session.`,\n\t\t};\n\t}\n\tconst ruleForCall = spec.ruleForCall(params as never);\n\tconst answer = await responder.ask({\n\t\tpreview: producePreview(spec, params),\n\t\ttoolName,\n\t\tdescription: ruleForCall !== null ? spec.describe(ruleForCall) : `Run ${toolName}`,\n\t\t// Only offer a session grant the persist branch below would actually write.\n\t\tsessionScope: ruleForCall !== null ? { description: spec.describe(ruleForCall) } : undefined,\n\t});\n\t// `persist` means the same thing on both branches: write the rule that decides\n\t// this exact call the way the user just decided it. Reading it only on the allow\n\t// branch is what let \"Reject always\" refuse once and then ask again.\n\tif (answer.persist && ruleForCall !== null) {\n\t\tawait options.store.apply({\n\t\t\ttype: \"addRules\",\n\t\t\tdestination: \"session\",\n\t\t\trules: [{ toolName, behavior: answer.allow ? \"allow\" : \"deny\", ruleContent: ruleForCall }],\n\t\t});\n\t}\n\tif (!answer.allow) {\n\t\treturn { block: true, reason: describeDecline(toolName, answer.guidance) };\n\t}\n\treturn { block: false };\n}\n\n/** Adapts evaluateToolCall() to the beforeToolCall seam agent-core already exposes. */\nexport function createPermissionGate(\n\toptions: PermissionGateOptions,\n): (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined> {\n\treturn async ({ toolCall, args }) => {\n\t\tconst decision = await evaluateToolCall(toolCall.name, args, options);\n\t\treturn decision.block ? { block: true, reason: decision.reason } : undefined;\n\t};\n}\n"]}
|
|
@@ -14,5 +14,5 @@
|
|
|
14
14
|
import type { Capability } from "../tools/contract.ts";
|
|
15
15
|
import type { PermissionResolution } from "./rules.ts";
|
|
16
16
|
import type { PermissionMode } from "./store.ts";
|
|
17
|
-
export declare function resolveWithMode(mode: PermissionMode, ruleResolution: PermissionResolution, capabilities: ReadonlySet<Capability
|
|
17
|
+
export declare function resolveWithMode(mode: PermissionMode, ruleResolution: PermissionResolution, capabilities: ReadonlySet<Capability>, withinWorkspace?: boolean, protectedTarget?: string): PermissionResolution;
|
|
18
18
|
//# sourceMappingURL=modes.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"modes.d.ts","sourceRoot":"","sources":["../../../src/core/permissions/modes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AACvD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAyBjD,wBAAgB,eAAe,CAC9B,IAAI,EAAE,cAAc,EACpB,cAAc,EAAE,oBAAoB,EACpC,YAAY,EAAE,WAAW,CAAC,UAAU,CAAC,
|
|
1
|
+
{"version":3,"file":"modes.d.ts","sourceRoot":"","sources":["../../../src/core/permissions/modes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AACvD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AACvD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAyBjD,wBAAgB,eAAe,CAC9B,IAAI,EAAE,cAAc,EACpB,cAAc,EAAE,oBAAoB,EACpC,YAAY,EAAE,WAAW,CAAC,UAAU,CAAC,EACrC,eAAe,CAAC,EAAE,OAAO,EACzB,eAAe,CAAC,EAAE,MAAM,GACtB,oBAAoB,CAiCtB","sourcesContent":["/**\n * Permission modes (roadmap Phase 2a). A mode is a resolution layer applied on top\n * of `resolvePermission()`'s rule-based result, driven entirely by a tool's\n * declared `capabilities` — never by tool name, so a mode behaves correctly for a\n * tool that does not exist yet.\n *\n * `plan` and `bypassPermissions` are absolute: they override even an explicit\n * matching rule, because that is their entire purpose (a hard safety floor, and an\n * explicit \"trust everything\" escape hatch, respectively). `acceptEdits` and\n * `dontAsk` are conveniences that only ever act on the *absence* of an explicit\n * rule — they change what a bare tool default resolves to, but never overrule a\n * rule the user actually set.\n */\n\nimport type { Capability } from \"../tools/contract.ts\";\nimport type { PermissionResolution } from \"./rules.ts\";\nimport type { PermissionMode } from \"./store.ts\";\n\n/**\n * Plan mode's hard floor. `state` is deliberately excluded: an explicit harness-state\n * tool such as `todo_write` is how plan mode maintains the plan it is presenting, so\n * denying it outright would make plan mode unable to do its own job (spec\n * `2026-08-13-tool-surface.md`, \"The problem\" item 4).\n */\nconst PLAN_MODE_DENIED_CAPABILITIES: readonly Capability[] = [\"fs.write\", \"exec\", \"delegate\"];\n\nfunction isPlanModeDenied(capabilities: ReadonlySet<Capability>): boolean {\n\treturn PLAN_MODE_DENIED_CAPABILITIES.some((capability) => capabilities.has(capability));\n}\n\n/** fs.write only, with no exec/net/delegate/state — the shape acceptEdits auto-allows. */\nfunction isEditShaped(capabilities: ReadonlySet<Capability>): boolean {\n\treturn (\n\t\tcapabilities.has(\"fs.write\") &&\n\t\t!capabilities.has(\"exec\") &&\n\t\t!capabilities.has(\"net\") &&\n\t\t!capabilities.has(\"delegate\") &&\n\t\t!capabilities.has(\"state\")\n\t);\n}\n\nexport function resolveWithMode(\n\tmode: PermissionMode,\n\truleResolution: PermissionResolution,\n\tcapabilities: ReadonlySet<Capability>,\n\twithinWorkspace?: boolean,\n\tprotectedTarget?: string,\n): PermissionResolution {\n\t// Plan mode is a hard mutating safety floor. Managed policy is otherwise\n\t// explicitly non-overridable (ADR 0004); bypass remains an escape hatch only\n\t// for lower-precedence sources.\n\tif (mode === \"plan\" && isPlanModeDenied(capabilities)) {\n\t\treturn { behavior: \"deny\" };\n\t}\n\tif (ruleResolution.rule?.source === \"policy\") {\n\t\treturn ruleResolution;\n\t}\n\t// After `policy`, so the machine owner's managed file can still grant it, and before\n\t// everything else, so a rule a repository can write cannot and neither can a mode.\n\t// `ask` rather than `deny`: the owner of the credential may legitimately want it read,\n\t// and a session with no responder turns this into a refusal anyway.\n\tif (protectedTarget !== undefined) {\n\t\treturn { behavior: \"ask\" };\n\t}\n\tif (mode === \"bypassPermissions\") {\n\t\treturn { behavior: \"allow\" };\n\t}\n\tif (ruleResolution.rule) {\n\t\t// An explicit matching rule is authoritative for every remaining mode.\n\t\treturn ruleResolution;\n\t}\n\tif (mode === \"dontAsk\" && ruleResolution.behavior === \"ask\") {\n\t\treturn { behavior: \"deny\" };\n\t}\n\t// `withinWorkspace` must be explicitly true. A tool that cannot say where it writes is\n\t// not auto-allowed, which is what the mounts used to decide before ADR 0032 removed them.\n\tif (mode === \"acceptEdits\" && ruleResolution.behavior === \"ask\" && isEditShaped(capabilities)) {\n\t\tif (withinWorkspace === true) return { behavior: \"allow\" };\n\t}\n\treturn ruleResolution;\n}\n"]}
|
|
@@ -29,7 +29,7 @@ function isEditShaped(capabilities) {
|
|
|
29
29
|
!capabilities.has("delegate") &&
|
|
30
30
|
!capabilities.has("state"));
|
|
31
31
|
}
|
|
32
|
-
export function resolveWithMode(mode, ruleResolution, capabilities) {
|
|
32
|
+
export function resolveWithMode(mode, ruleResolution, capabilities, withinWorkspace, protectedTarget) {
|
|
33
33
|
// Plan mode is a hard mutating safety floor. Managed policy is otherwise
|
|
34
34
|
// explicitly non-overridable (ADR 0004); bypass remains an escape hatch only
|
|
35
35
|
// for lower-precedence sources.
|
|
@@ -39,6 +39,13 @@ export function resolveWithMode(mode, ruleResolution, capabilities) {
|
|
|
39
39
|
if (ruleResolution.rule?.source === "policy") {
|
|
40
40
|
return ruleResolution;
|
|
41
41
|
}
|
|
42
|
+
// After `policy`, so the machine owner's managed file can still grant it, and before
|
|
43
|
+
// everything else, so a rule a repository can write cannot and neither can a mode.
|
|
44
|
+
// `ask` rather than `deny`: the owner of the credential may legitimately want it read,
|
|
45
|
+
// and a session with no responder turns this into a refusal anyway.
|
|
46
|
+
if (protectedTarget !== undefined) {
|
|
47
|
+
return { behavior: "ask" };
|
|
48
|
+
}
|
|
42
49
|
if (mode === "bypassPermissions") {
|
|
43
50
|
return { behavior: "allow" };
|
|
44
51
|
}
|
|
@@ -49,8 +56,11 @@ export function resolveWithMode(mode, ruleResolution, capabilities) {
|
|
|
49
56
|
if (mode === "dontAsk" && ruleResolution.behavior === "ask") {
|
|
50
57
|
return { behavior: "deny" };
|
|
51
58
|
}
|
|
59
|
+
// `withinWorkspace` must be explicitly true. A tool that cannot say where it writes is
|
|
60
|
+
// not auto-allowed, which is what the mounts used to decide before ADR 0032 removed them.
|
|
52
61
|
if (mode === "acceptEdits" && ruleResolution.behavior === "ask" && isEditShaped(capabilities)) {
|
|
53
|
-
|
|
62
|
+
if (withinWorkspace === true)
|
|
63
|
+
return { behavior: "allow" };
|
|
54
64
|
}
|
|
55
65
|
return ruleResolution;
|
|
56
66
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"modes.js","sourceRoot":"","sources":["../../../src/core/permissions/modes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH;;;;;GAKG;AACH,MAAM,6BAA6B,GAA0B,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,CAAC,CAAC;AAE9F,SAAS,gBAAgB,CAAC,YAAqC,EAAW;IACzE,OAAO,6BAA6B,CAAC,IAAI,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC;AAAA,CACxF;AAED,4FAA0F;AAC1F,SAAS,YAAY,CAAC,YAAqC,EAAW;IACrE,OAAO,CACN,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC;QAC5B,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC;QACzB,CAAC,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC;QACxB,CAAC,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC;QAC7B,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAC1B,CAAC;AAAA,CACF;AAED,MAAM,UAAU,eAAe,CAC9B,IAAoB,EACpB,cAAoC,EACpC,YAAqC,
|
|
1
|
+
{"version":3,"file":"modes.js","sourceRoot":"","sources":["../../../src/core/permissions/modes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAMH;;;;;GAKG;AACH,MAAM,6BAA6B,GAA0B,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,CAAC,CAAC;AAE9F,SAAS,gBAAgB,CAAC,YAAqC,EAAW;IACzE,OAAO,6BAA6B,CAAC,IAAI,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC;AAAA,CACxF;AAED,4FAA0F;AAC1F,SAAS,YAAY,CAAC,YAAqC,EAAW;IACrE,OAAO,CACN,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC;QAC5B,CAAC,YAAY,CAAC,GAAG,CAAC,MAAM,CAAC;QACzB,CAAC,YAAY,CAAC,GAAG,CAAC,KAAK,CAAC;QACxB,CAAC,YAAY,CAAC,GAAG,CAAC,UAAU,CAAC;QAC7B,CAAC,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,CAC1B,CAAC;AAAA,CACF;AAED,MAAM,UAAU,eAAe,CAC9B,IAAoB,EACpB,cAAoC,EACpC,YAAqC,EACrC,eAAyB,EACzB,eAAwB,EACD;IACvB,yEAAyE;IACzE,6EAA6E;IAC7E,gCAAgC;IAChC,IAAI,IAAI,KAAK,MAAM,IAAI,gBAAgB,CAAC,YAAY,CAAC,EAAE,CAAC;QACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAC7B,CAAC;IACD,IAAI,cAAc,CAAC,IAAI,EAAE,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC9C,OAAO,cAAc,CAAC;IACvB,CAAC;IACD,qFAAqF;IACrF,mFAAmF;IACnF,uFAAuF;IACvF,oEAAoE;IACpE,IAAI,eAAe,KAAK,SAAS,EAAE,CAAC;QACnC,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;IAC5B,CAAC;IACD,IAAI,IAAI,KAAK,mBAAmB,EAAE,CAAC;QAClC,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;IAC9B,CAAC;IACD,IAAI,cAAc,CAAC,IAAI,EAAE,CAAC;QACzB,uEAAuE;QACvE,OAAO,cAAc,CAAC;IACvB,CAAC;IACD,IAAI,IAAI,KAAK,SAAS,IAAI,cAAc,CAAC,QAAQ,KAAK,KAAK,EAAE,CAAC;QAC7D,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAC7B,CAAC;IACD,uFAAuF;IACvF,0FAA0F;IAC1F,IAAI,IAAI,KAAK,aAAa,IAAI,cAAc,CAAC,QAAQ,KAAK,KAAK,IAAI,YAAY,CAAC,YAAY,CAAC,EAAE,CAAC;QAC/F,IAAI,eAAe,KAAK,IAAI;YAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC;IAC5D,CAAC;IACD,OAAO,cAAc,CAAC;AAAA,CACtB","sourcesContent":["/**\n * Permission modes (roadmap Phase 2a). A mode is a resolution layer applied on top\n * of `resolvePermission()`'s rule-based result, driven entirely by a tool's\n * declared `capabilities` — never by tool name, so a mode behaves correctly for a\n * tool that does not exist yet.\n *\n * `plan` and `bypassPermissions` are absolute: they override even an explicit\n * matching rule, because that is their entire purpose (a hard safety floor, and an\n * explicit \"trust everything\" escape hatch, respectively). `acceptEdits` and\n * `dontAsk` are conveniences that only ever act on the *absence* of an explicit\n * rule — they change what a bare tool default resolves to, but never overrule a\n * rule the user actually set.\n */\n\nimport type { Capability } from \"../tools/contract.ts\";\nimport type { PermissionResolution } from \"./rules.ts\";\nimport type { PermissionMode } from \"./store.ts\";\n\n/**\n * Plan mode's hard floor. `state` is deliberately excluded: an explicit harness-state\n * tool such as `todo_write` is how plan mode maintains the plan it is presenting, so\n * denying it outright would make plan mode unable to do its own job (spec\n * `2026-08-13-tool-surface.md`, \"The problem\" item 4).\n */\nconst PLAN_MODE_DENIED_CAPABILITIES: readonly Capability[] = [\"fs.write\", \"exec\", \"delegate\"];\n\nfunction isPlanModeDenied(capabilities: ReadonlySet<Capability>): boolean {\n\treturn PLAN_MODE_DENIED_CAPABILITIES.some((capability) => capabilities.has(capability));\n}\n\n/** fs.write only, with no exec/net/delegate/state — the shape acceptEdits auto-allows. */\nfunction isEditShaped(capabilities: ReadonlySet<Capability>): boolean {\n\treturn (\n\t\tcapabilities.has(\"fs.write\") &&\n\t\t!capabilities.has(\"exec\") &&\n\t\t!capabilities.has(\"net\") &&\n\t\t!capabilities.has(\"delegate\") &&\n\t\t!capabilities.has(\"state\")\n\t);\n}\n\nexport function resolveWithMode(\n\tmode: PermissionMode,\n\truleResolution: PermissionResolution,\n\tcapabilities: ReadonlySet<Capability>,\n\twithinWorkspace?: boolean,\n\tprotectedTarget?: string,\n): PermissionResolution {\n\t// Plan mode is a hard mutating safety floor. Managed policy is otherwise\n\t// explicitly non-overridable (ADR 0004); bypass remains an escape hatch only\n\t// for lower-precedence sources.\n\tif (mode === \"plan\" && isPlanModeDenied(capabilities)) {\n\t\treturn { behavior: \"deny\" };\n\t}\n\tif (ruleResolution.rule?.source === \"policy\") {\n\t\treturn ruleResolution;\n\t}\n\t// After `policy`, so the machine owner's managed file can still grant it, and before\n\t// everything else, so a rule a repository can write cannot and neither can a mode.\n\t// `ask` rather than `deny`: the owner of the credential may legitimately want it read,\n\t// and a session with no responder turns this into a refusal anyway.\n\tif (protectedTarget !== undefined) {\n\t\treturn { behavior: \"ask\" };\n\t}\n\tif (mode === \"bypassPermissions\") {\n\t\treturn { behavior: \"allow\" };\n\t}\n\tif (ruleResolution.rule) {\n\t\t// An explicit matching rule is authoritative for every remaining mode.\n\t\treturn ruleResolution;\n\t}\n\tif (mode === \"dontAsk\" && ruleResolution.behavior === \"ask\") {\n\t\treturn { behavior: \"deny\" };\n\t}\n\t// `withinWorkspace` must be explicitly true. A tool that cannot say where it writes is\n\t// not auto-allowed, which is what the mounts used to decide before ADR 0032 removed them.\n\tif (mode === \"acceptEdits\" && ruleResolution.behavior === \"ask\" && isEditShaped(capabilities)) {\n\t\tif (withinWorkspace === true) return { behavior: \"allow\" };\n\t}\n\treturn ruleResolution;\n}\n"]}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paths the gate refuses to hand to a tool without an explicit decision.
|
|
3
|
+
*
|
|
4
|
+
* The agent directory's credential file is the only entry. It is the one file a session
|
|
5
|
+
* can read that the harness itself wrote, it holds provider keys in cleartext, and its
|
|
6
|
+
* 0600 mode protects it from other accounts rather than from this one. While the process
|
|
7
|
+
* boundary existed the home directory was hidden and this file was deliberately mounted;
|
|
8
|
+
* ADR 0032 removed the boundary and left the read-shaped tools at `allow`.
|
|
9
|
+
*
|
|
10
|
+
* This is not containment and does not pretend to be. A session can still run `cat` under
|
|
11
|
+
* the `bash` tool, which asks. It closes the path that asked nothing.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Why this path is protected, or undefined when it is ordinary.
|
|
15
|
+
*
|
|
16
|
+
* Compares through `realpathSync` on both sides, so a symlink to the credential file is
|
|
17
|
+
* the credential file. Ancestors count because `grep`, `ls`, and `find` take a directory
|
|
18
|
+
* and reach everything under it, so refusing only the exact file would leave the content
|
|
19
|
+
* reachable by naming its parent. Only true ancestors of the credential file match, so an
|
|
20
|
+
* ordinary workspace path is unaffected.
|
|
21
|
+
*/
|
|
22
|
+
export declare function describeProtectedTarget(path: string): string | undefined;
|
|
23
|
+
//# sourceMappingURL=protected-paths.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protected-paths.d.ts","sourceRoot":"","sources":["../../../src/core/permissions/protected-paths.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAaxE","sourcesContent":["/**\n * Paths the gate refuses to hand to a tool without an explicit decision.\n *\n * The agent directory's credential file is the only entry. It is the one file a session\n * can read that the harness itself wrote, it holds provider keys in cleartext, and its\n * 0600 mode protects it from other accounts rather than from this one. While the process\n * boundary existed the home directory was hidden and this file was deliberately mounted;\n * ADR 0032 removed the boundary and left the read-shaped tools at `allow`.\n *\n * This is not containment and does not pretend to be. A session can still run `cat` under\n * the `bash` tool, which asks. It closes the path that asked nothing.\n */\n\nimport { dirname } from \"node:path\";\nimport { getAuthPath } from \"../../config.ts\";\nimport { canonicalizePath, resolvePath } from \"../../utils/paths.ts\";\n\n/**\n * Why this path is protected, or undefined when it is ordinary.\n *\n * Compares through `realpathSync` on both sides, so a symlink to the credential file is\n * the credential file. Ancestors count because `grep`, `ls`, and `find` take a directory\n * and reach everything under it, so refusing only the exact file would leave the content\n * reachable by naming its parent. Only true ancestors of the credential file match, so an\n * ordinary workspace path is unaffected.\n */\nexport function describeProtectedTarget(path: string): string | undefined {\n\tconst credentialPath = getAuthPath();\n\tconst credential = canonicalizePath(resolvePath(credentialPath));\n\tconst target = canonicalizePath(resolvePath(path));\n\tif (target === credential) return `${credentialPath} holds this session's provider credentials`;\n\n\tlet ancestor = dirname(credential);\n\twhile (true) {\n\t\tif (target === ancestor) return `${target} contains ${credentialPath}, which holds provider credentials`;\n\t\tconst parent = dirname(ancestor);\n\t\tif (parent === ancestor) return undefined;\n\t\tancestor = parent;\n\t}\n}\n"]}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paths the gate refuses to hand to a tool without an explicit decision.
|
|
3
|
+
*
|
|
4
|
+
* The agent directory's credential file is the only entry. It is the one file a session
|
|
5
|
+
* can read that the harness itself wrote, it holds provider keys in cleartext, and its
|
|
6
|
+
* 0600 mode protects it from other accounts rather than from this one. While the process
|
|
7
|
+
* boundary existed the home directory was hidden and this file was deliberately mounted;
|
|
8
|
+
* ADR 0032 removed the boundary and left the read-shaped tools at `allow`.
|
|
9
|
+
*
|
|
10
|
+
* This is not containment and does not pretend to be. A session can still run `cat` under
|
|
11
|
+
* the `bash` tool, which asks. It closes the path that asked nothing.
|
|
12
|
+
*/
|
|
13
|
+
import { dirname } from "node:path";
|
|
14
|
+
import { getAuthPath } from "../../config.js";
|
|
15
|
+
import { canonicalizePath, resolvePath } from "../../utils/paths.js";
|
|
16
|
+
/**
|
|
17
|
+
* Why this path is protected, or undefined when it is ordinary.
|
|
18
|
+
*
|
|
19
|
+
* Compares through `realpathSync` on both sides, so a symlink to the credential file is
|
|
20
|
+
* the credential file. Ancestors count because `grep`, `ls`, and `find` take a directory
|
|
21
|
+
* and reach everything under it, so refusing only the exact file would leave the content
|
|
22
|
+
* reachable by naming its parent. Only true ancestors of the credential file match, so an
|
|
23
|
+
* ordinary workspace path is unaffected.
|
|
24
|
+
*/
|
|
25
|
+
export function describeProtectedTarget(path) {
|
|
26
|
+
const credentialPath = getAuthPath();
|
|
27
|
+
const credential = canonicalizePath(resolvePath(credentialPath));
|
|
28
|
+
const target = canonicalizePath(resolvePath(path));
|
|
29
|
+
if (target === credential)
|
|
30
|
+
return `${credentialPath} holds this session's provider credentials`;
|
|
31
|
+
let ancestor = dirname(credential);
|
|
32
|
+
while (true) {
|
|
33
|
+
if (target === ancestor)
|
|
34
|
+
return `${target} contains ${credentialPath}, which holds provider credentials`;
|
|
35
|
+
const parent = dirname(ancestor);
|
|
36
|
+
if (parent === ancestor)
|
|
37
|
+
return undefined;
|
|
38
|
+
ancestor = parent;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=protected-paths.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protected-paths.js","sourceRoot":"","sources":["../../../src/core/permissions/protected-paths.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAC9C,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAErE;;;;;;;;GAQG;AACH,MAAM,UAAU,uBAAuB,CAAC,IAAY,EAAsB;IACzE,MAAM,cAAc,GAAG,WAAW,EAAE,CAAC;IACrC,MAAM,UAAU,GAAG,gBAAgB,CAAC,WAAW,CAAC,cAAc,CAAC,CAAC,CAAC;IACjE,MAAM,MAAM,GAAG,gBAAgB,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC;IACnD,IAAI,MAAM,KAAK,UAAU;QAAE,OAAO,GAAG,cAAc,4CAA4C,CAAC;IAEhG,IAAI,QAAQ,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IACnC,OAAO,IAAI,EAAE,CAAC;QACb,IAAI,MAAM,KAAK,QAAQ;YAAE,OAAO,GAAG,MAAM,aAAa,cAAc,oCAAoC,CAAC;QACzG,MAAM,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;QACjC,IAAI,MAAM,KAAK,QAAQ;YAAE,OAAO,SAAS,CAAC;QAC1C,QAAQ,GAAG,MAAM,CAAC;IACnB,CAAC;AAAA,CACD","sourcesContent":["/**\n * Paths the gate refuses to hand to a tool without an explicit decision.\n *\n * The agent directory's credential file is the only entry. It is the one file a session\n * can read that the harness itself wrote, it holds provider keys in cleartext, and its\n * 0600 mode protects it from other accounts rather than from this one. While the process\n * boundary existed the home directory was hidden and this file was deliberately mounted;\n * ADR 0032 removed the boundary and left the read-shaped tools at `allow`.\n *\n * This is not containment and does not pretend to be. A session can still run `cat` under\n * the `bash` tool, which asks. It closes the path that asked nothing.\n */\n\nimport { dirname } from \"node:path\";\nimport { getAuthPath } from \"../../config.ts\";\nimport { canonicalizePath, resolvePath } from \"../../utils/paths.ts\";\n\n/**\n * Why this path is protected, or undefined when it is ordinary.\n *\n * Compares through `realpathSync` on both sides, so a symlink to the credential file is\n * the credential file. Ancestors count because `grep`, `ls`, and `find` take a directory\n * and reach everything under it, so refusing only the exact file would leave the content\n * reachable by naming its parent. Only true ancestors of the credential file match, so an\n * ordinary workspace path is unaffected.\n */\nexport function describeProtectedTarget(path: string): string | undefined {\n\tconst credentialPath = getAuthPath();\n\tconst credential = canonicalizePath(resolvePath(credentialPath));\n\tconst target = canonicalizePath(resolvePath(path));\n\tif (target === credential) return `${credentialPath} holds this session's provider credentials`;\n\n\tlet ancestor = dirname(credential);\n\twhile (true) {\n\t\tif (target === ancestor) return `${target} contains ${credentialPath}, which holds provider credentials`;\n\t\tconst parent = dirname(ancestor);\n\t\tif (parent === ancestor) return undefined;\n\t\tancestor = parent;\n\t}\n}\n"]}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings keys that ADR 0032 removed along with the process boundary.
|
|
3
|
+
*
|
|
4
|
+
* The removed CLI flags fail loudly: `cli/args.ts` collects an unrecognized flag and
|
|
5
|
+
* `main.ts` exits non-zero before a session starts. A settings file has no equivalent,
|
|
6
|
+
* because the loader ignores keys it does not know and keeps going. So an operator who
|
|
7
|
+
* restricted egress in 0.0.6 upgrades, keeps their file, and loses the restriction with
|
|
8
|
+
* nothing said. The changelog presents the two removals as equivalent. They are not
|
|
9
|
+
* unless this path speaks.
|
|
10
|
+
*
|
|
11
|
+
* These names live in one module because a deprecation notice has to name what it
|
|
12
|
+
* deprecates, and `scripts/apex/no-os-sandbox-surface.test.mjs` otherwise forbids these
|
|
13
|
+
* identifiers anywhere in `src`. That guard grants this file a narrow exemption.
|
|
14
|
+
*/
|
|
15
|
+
import type { SettingsScope } from "./settings-manager.ts";
|
|
16
|
+
interface RemovedSetting {
|
|
17
|
+
/** Where the key sits in a settings object. */
|
|
18
|
+
readonly path: readonly string[];
|
|
19
|
+
/** How the key is written in a settings file, for the message. */
|
|
20
|
+
readonly name: string;
|
|
21
|
+
}
|
|
22
|
+
export declare const REMOVED_SETTINGS: readonly RemovedSetting[];
|
|
23
|
+
export declare function findRemovedSettings(settings: object): string[];
|
|
24
|
+
export declare function removedSettingsMessage(scope: SettingsScope, path: string | undefined, names: readonly string[]): string;
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=removed-settings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"removed-settings.d.ts","sourceRoot":"","sources":["../../src/core/removed-settings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,UAAU,cAAc;IACvB,+CAA+C;IAC/C,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,kEAAkE;IAClE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,gBAAgB,EAAE,SAAS,cAAc,EAIrD,CAAC;AAYF,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,CAE9D;AAED,wBAAgB,sBAAsB,CACrC,KAAK,EAAE,aAAa,EACpB,IAAI,EAAE,MAAM,GAAG,SAAS,EACxB,KAAK,EAAE,SAAS,MAAM,EAAE,GACtB,MAAM,CAMR","sourcesContent":["/**\n * Settings keys that ADR 0032 removed along with the process boundary.\n *\n * The removed CLI flags fail loudly: `cli/args.ts` collects an unrecognized flag and\n * `main.ts` exits non-zero before a session starts. A settings file has no equivalent,\n * because the loader ignores keys it does not know and keeps going. So an operator who\n * restricted egress in 0.0.6 upgrades, keeps their file, and loses the restriction with\n * nothing said. The changelog presents the two removals as equivalent. They are not\n * unless this path speaks.\n *\n * These names live in one module because a deprecation notice has to name what it\n * deprecates, and `scripts/apex/no-os-sandbox-surface.test.mjs` otherwise forbids these\n * identifiers anywhere in `src`. That guard grants this file a narrow exemption.\n */\n\nimport type { SettingsScope } from \"./settings-manager.ts\";\n\ninterface RemovedSetting {\n\t/** Where the key sits in a settings object. */\n\treadonly path: readonly string[];\n\t/** How the key is written in a settings file, for the message. */\n\treadonly name: string;\n}\n\nexport const REMOVED_SETTINGS: readonly RemovedSetting[] = [\n\t{ path: [\"network\", \"allowedHosts\"], name: \"network.allowedHosts\" },\n\t{ path: [\"network\", \"allowDefaultHosts\"], name: \"network.allowDefaultHosts\" },\n\t{ path: [\"sandboxProfiles\"], name: \"sandboxProfiles\" },\n];\n\n/** True when the key is present at all. A key set to `false` or `[]` was still configured. */\nfunction isPresent(settings: object, path: readonly string[]): boolean {\n\tlet current: unknown = settings;\n\tfor (const segment of path) {\n\t\tif (typeof current !== \"object\" || current === null) return false;\n\t\tcurrent = (current as Record<string, unknown>)[segment];\n\t}\n\treturn current !== undefined;\n}\n\nexport function findRemovedSettings(settings: object): string[] {\n\treturn REMOVED_SETTINGS.filter((setting) => isPresent(settings, setting.path)).map((setting) => setting.name);\n}\n\nexport function removedSettingsMessage(\n\tscope: SettingsScope,\n\tpath: string | undefined,\n\tnames: readonly string[],\n): string {\n\tconst where = scope === \"global\" ? \"Global settings\" : \"Project settings\";\n\tconst location = path ? ` (${path})` : \"\";\n\tconst keys = names.join(\", \");\n\tconst verb = names.length === 1 ? \"it no longer restricts\" : \"they no longer restrict\";\n\treturn `${where}${location} still set ${keys}. Apex Code provides no isolation of its own (ADR 0032), so ${verb} anything. Delete the keys.`;\n}\n"]}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings keys that ADR 0032 removed along with the process boundary.
|
|
3
|
+
*
|
|
4
|
+
* The removed CLI flags fail loudly: `cli/args.ts` collects an unrecognized flag and
|
|
5
|
+
* `main.ts` exits non-zero before a session starts. A settings file has no equivalent,
|
|
6
|
+
* because the loader ignores keys it does not know and keeps going. So an operator who
|
|
7
|
+
* restricted egress in 0.0.6 upgrades, keeps their file, and loses the restriction with
|
|
8
|
+
* nothing said. The changelog presents the two removals as equivalent. They are not
|
|
9
|
+
* unless this path speaks.
|
|
10
|
+
*
|
|
11
|
+
* These names live in one module because a deprecation notice has to name what it
|
|
12
|
+
* deprecates, and `scripts/apex/no-os-sandbox-surface.test.mjs` otherwise forbids these
|
|
13
|
+
* identifiers anywhere in `src`. That guard grants this file a narrow exemption.
|
|
14
|
+
*/
|
|
15
|
+
export const REMOVED_SETTINGS = [
|
|
16
|
+
{ path: ["network", "allowedHosts"], name: "network.allowedHosts" },
|
|
17
|
+
{ path: ["network", "allowDefaultHosts"], name: "network.allowDefaultHosts" },
|
|
18
|
+
{ path: ["sandboxProfiles"], name: "sandboxProfiles" },
|
|
19
|
+
];
|
|
20
|
+
/** True when the key is present at all. A key set to `false` or `[]` was still configured. */
|
|
21
|
+
function isPresent(settings, path) {
|
|
22
|
+
let current = settings;
|
|
23
|
+
for (const segment of path) {
|
|
24
|
+
if (typeof current !== "object" || current === null)
|
|
25
|
+
return false;
|
|
26
|
+
current = current[segment];
|
|
27
|
+
}
|
|
28
|
+
return current !== undefined;
|
|
29
|
+
}
|
|
30
|
+
export function findRemovedSettings(settings) {
|
|
31
|
+
return REMOVED_SETTINGS.filter((setting) => isPresent(settings, setting.path)).map((setting) => setting.name);
|
|
32
|
+
}
|
|
33
|
+
export function removedSettingsMessage(scope, path, names) {
|
|
34
|
+
const where = scope === "global" ? "Global settings" : "Project settings";
|
|
35
|
+
const location = path ? ` (${path})` : "";
|
|
36
|
+
const keys = names.join(", ");
|
|
37
|
+
const verb = names.length === 1 ? "it no longer restricts" : "they no longer restrict";
|
|
38
|
+
return `${where}${location} still set ${keys}. Apex Code provides no isolation of its own (ADR 0032), so ${verb} anything. Delete the keys.`;
|
|
39
|
+
}
|
|
40
|
+
//# sourceMappingURL=removed-settings.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"removed-settings.js","sourceRoot":"","sources":["../../src/core/removed-settings.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAWH,MAAM,CAAC,MAAM,gBAAgB,GAA8B;IAC1D,EAAE,IAAI,EAAE,CAAC,SAAS,EAAE,cAAc,CAAC,EAAE,IAAI,EAAE,sBAAsB,EAAE;IACnE,EAAE,IAAI,EAAE,CAAC,SAAS,EAAE,mBAAmB,CAAC,EAAE,IAAI,EAAE,2BAA2B,EAAE;IAC7E,EAAE,IAAI,EAAE,CAAC,iBAAiB,CAAC,EAAE,IAAI,EAAE,iBAAiB,EAAE;CACtD,CAAC;AAEF,8FAA8F;AAC9F,SAAS,SAAS,CAAC,QAAgB,EAAE,IAAuB,EAAW;IACtE,IAAI,OAAO,GAAY,QAAQ,CAAC;IAChC,KAAK,MAAM,OAAO,IAAI,IAAI,EAAE,CAAC;QAC5B,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;QAClE,OAAO,GAAI,OAAmC,CAAC,OAAO,CAAC,CAAC;IACzD,CAAC;IACD,OAAO,OAAO,KAAK,SAAS,CAAC;AAAA,CAC7B;AAED,MAAM,UAAU,mBAAmB,CAAC,QAAgB,EAAY;IAC/D,OAAO,gBAAgB,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,SAAS,CAAC,QAAQ,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;AAAA,CAC9G;AAED,MAAM,UAAU,sBAAsB,CACrC,KAAoB,EACpB,IAAwB,EACxB,KAAwB,EACf;IACT,MAAM,KAAK,GAAG,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,iBAAiB,CAAC,CAAC,CAAC,kBAAkB,CAAC;IAC1E,MAAM,QAAQ,GAAG,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1C,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC9B,MAAM,IAAI,GAAG,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,wBAAwB,CAAC,CAAC,CAAC,yBAAyB,CAAC;IACvF,OAAO,GAAG,KAAK,GAAG,QAAQ,cAAc,IAAI,+DAA+D,IAAI,6BAA6B,CAAC;AAAA,CAC7I","sourcesContent":["/**\n * Settings keys that ADR 0032 removed along with the process boundary.\n *\n * The removed CLI flags fail loudly: `cli/args.ts` collects an unrecognized flag and\n * `main.ts` exits non-zero before a session starts. A settings file has no equivalent,\n * because the loader ignores keys it does not know and keeps going. So an operator who\n * restricted egress in 0.0.6 upgrades, keeps their file, and loses the restriction with\n * nothing said. The changelog presents the two removals as equivalent. They are not\n * unless this path speaks.\n *\n * These names live in one module because a deprecation notice has to name what it\n * deprecates, and `scripts/apex/no-os-sandbox-surface.test.mjs` otherwise forbids these\n * identifiers anywhere in `src`. That guard grants this file a narrow exemption.\n */\n\nimport type { SettingsScope } from \"./settings-manager.ts\";\n\ninterface RemovedSetting {\n\t/** Where the key sits in a settings object. */\n\treadonly path: readonly string[];\n\t/** How the key is written in a settings file, for the message. */\n\treadonly name: string;\n}\n\nexport const REMOVED_SETTINGS: readonly RemovedSetting[] = [\n\t{ path: [\"network\", \"allowedHosts\"], name: \"network.allowedHosts\" },\n\t{ path: [\"network\", \"allowDefaultHosts\"], name: \"network.allowDefaultHosts\" },\n\t{ path: [\"sandboxProfiles\"], name: \"sandboxProfiles\" },\n];\n\n/** True when the key is present at all. A key set to `false` or `[]` was still configured. */\nfunction isPresent(settings: object, path: readonly string[]): boolean {\n\tlet current: unknown = settings;\n\tfor (const segment of path) {\n\t\tif (typeof current !== \"object\" || current === null) return false;\n\t\tcurrent = (current as Record<string, unknown>)[segment];\n\t}\n\treturn current !== undefined;\n}\n\nexport function findRemovedSettings(settings: object): string[] {\n\treturn REMOVED_SETTINGS.filter((setting) => isPresent(settings, setting.path)).map((setting) => setting.name);\n}\n\nexport function removedSettingsMessage(\n\tscope: SettingsScope,\n\tpath: string | undefined,\n\tnames: readonly string[],\n): string {\n\tconst where = scope === \"global\" ? \"Global settings\" : \"Project settings\";\n\tconst location = path ? ` (${path})` : \"\";\n\tconst keys = names.join(\", \");\n\tconst verb = names.length === 1 ? \"it no longer restricts\" : \"they no longer restrict\";\n\treturn `${where}${location} still set ${keys}. Apex Code provides no isolation of its own (ADR 0032), so ${verb} anything. Delete the keys.`;\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settings-diagnostics.d.ts","sourceRoot":"","sources":["../../src/core/settings-diagnostics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"settings-diagnostics.d.ts","sourceRoot":"","sources":["../../src/core/settings-diagnostics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,6BAA6B,CAAC;AAEjF,OAAO,KAAK,EAAE,eAAe,EAAiB,MAAM,uBAAuB,CAAC;AAE5E,wBAAgB,0BAA0B,CAAC,eAAe,EAAE,eAAe,GAAG,6BAA6B,EAAE,CAoB5G;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CACrC,WAAW,EAAE,SAAS,6BAA6B,EAAE,GACnD,6BAA6B,EAAE,CAQjC","sourcesContent":["import type { AgentSessionRuntimeDiagnostic } from \"./agent-session-services.ts\";\nimport { findRemovedSettings, removedSettingsMessage } from \"./removed-settings.ts\";\nimport type { SettingsManager, SettingsScope } from \"./settings-manager.ts\";\n\nexport function collectSettingsDiagnostics(settingsManager: SettingsManager): AgentSessionRuntimeDiagnostic[] {\n\tconst diagnostics: AgentSessionRuntimeDiagnostic[] = settingsManager.drainErrors().map(({ scope, path, error }) => ({\n\t\ttype: \"warning\",\n\t\tmessage: path ? `Invalid settings file ${path}: ${error.message}` : `Invalid ${scope} settings: ${error.message}`,\n\t}));\n\n\tconst scopes: ReadonlyArray<[SettingsScope, object]> = [\n\t\t[\"global\", settingsManager.getGlobalSettings()],\n\t\t[\"project\", settingsManager.getProjectSettings()],\n\t];\n\tfor (const [scope, settings] of scopes) {\n\t\tconst names = findRemovedSettings(settings);\n\t\tif (names.length === 0) continue;\n\t\tdiagnostics.push({\n\t\t\ttype: \"warning\",\n\t\t\tmessage: removedSettingsMessage(scope, settingsManager.getSettingsPath(scope), names),\n\t\t});\n\t}\n\n\treturn diagnostics;\n}\n\n/**\n * Remove duplicate type/message diagnostics while preserving their first occurrence.\n * Startup and runtime settings managers can report the same file error.\n */\nexport function deduplicateDiagnostics(\n\tdiagnostics: readonly AgentSessionRuntimeDiagnostic[],\n): AgentSessionRuntimeDiagnostic[] {\n\tconst seen = new Set<string>();\n\treturn diagnostics.filter((diagnostic) => {\n\t\tconst key = `${diagnostic.type}\\0${diagnostic.message}`;\n\t\tif (seen.has(key)) return false;\n\t\tseen.add(key);\n\t\treturn true;\n\t});\n}\n"]}
|
|
@@ -1,8 +1,23 @@
|
|
|
1
|
+
import { findRemovedSettings, removedSettingsMessage } from "./removed-settings.js";
|
|
1
2
|
export function collectSettingsDiagnostics(settingsManager) {
|
|
2
|
-
|
|
3
|
+
const diagnostics = settingsManager.drainErrors().map(({ scope, path, error }) => ({
|
|
3
4
|
type: "warning",
|
|
4
5
|
message: path ? `Invalid settings file ${path}: ${error.message}` : `Invalid ${scope} settings: ${error.message}`,
|
|
5
6
|
}));
|
|
7
|
+
const scopes = [
|
|
8
|
+
["global", settingsManager.getGlobalSettings()],
|
|
9
|
+
["project", settingsManager.getProjectSettings()],
|
|
10
|
+
];
|
|
11
|
+
for (const [scope, settings] of scopes) {
|
|
12
|
+
const names = findRemovedSettings(settings);
|
|
13
|
+
if (names.length === 0)
|
|
14
|
+
continue;
|
|
15
|
+
diagnostics.push({
|
|
16
|
+
type: "warning",
|
|
17
|
+
message: removedSettingsMessage(scope, settingsManager.getSettingsPath(scope), names),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return diagnostics;
|
|
6
21
|
}
|
|
7
22
|
/**
|
|
8
23
|
* Remove duplicate type/message diagnostics while preserving their first occurrence.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settings-diagnostics.js","sourceRoot":"","sources":["../../src/core/settings-diagnostics.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"settings-diagnostics.js","sourceRoot":"","sources":["../../src/core/settings-diagnostics.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAGpF,MAAM,UAAU,0BAA0B,CAAC,eAAgC,EAAmC;IAC7G,MAAM,WAAW,GAAoC,eAAe,CAAC,WAAW,EAAE,CAAC,GAAG,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,CAAC;QACnH,IAAI,EAAE,SAAS;QACf,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,yBAAyB,IAAI,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,WAAW,KAAK,cAAc,KAAK,CAAC,OAAO,EAAE;KACjH,CAAC,CAAC,CAAC;IAEJ,MAAM,MAAM,GAA2C;QACtD,CAAC,QAAQ,EAAE,eAAe,CAAC,iBAAiB,EAAE,CAAC;QAC/C,CAAC,SAAS,EAAE,eAAe,CAAC,kBAAkB,EAAE,CAAC;KACjD,CAAC;IACF,KAAK,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,IAAI,MAAM,EAAE,CAAC;QACxC,MAAM,KAAK,GAAG,mBAAmB,CAAC,QAAQ,CAAC,CAAC;QAC5C,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QACjC,WAAW,CAAC,IAAI,CAAC;YAChB,IAAI,EAAE,SAAS;YACf,OAAO,EAAE,sBAAsB,CAAC,KAAK,EAAE,eAAe,CAAC,eAAe,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC;SACrF,CAAC,CAAC;IACJ,CAAC;IAED,OAAO,WAAW,CAAC;AAAA,CACnB;AAED;;;GAGG;AACH,MAAM,UAAU,sBAAsB,CACrC,WAAqD,EACnB;IAClC,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,OAAO,WAAW,CAAC,MAAM,CAAC,CAAC,UAAU,EAAE,EAAE,CAAC;QACzC,MAAM,GAAG,GAAG,GAAG,UAAU,CAAC,IAAI,KAAK,UAAU,CAAC,OAAO,EAAE,CAAC;QACxD,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,OAAO,KAAK,CAAC;QAChC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QACd,OAAO,IAAI,CAAC;IAAA,CACZ,CAAC,CAAC;AAAA,CACH","sourcesContent":["import type { AgentSessionRuntimeDiagnostic } from \"./agent-session-services.ts\";\nimport { findRemovedSettings, removedSettingsMessage } from \"./removed-settings.ts\";\nimport type { SettingsManager, SettingsScope } from \"./settings-manager.ts\";\n\nexport function collectSettingsDiagnostics(settingsManager: SettingsManager): AgentSessionRuntimeDiagnostic[] {\n\tconst diagnostics: AgentSessionRuntimeDiagnostic[] = settingsManager.drainErrors().map(({ scope, path, error }) => ({\n\t\ttype: \"warning\",\n\t\tmessage: path ? `Invalid settings file ${path}: ${error.message}` : `Invalid ${scope} settings: ${error.message}`,\n\t}));\n\n\tconst scopes: ReadonlyArray<[SettingsScope, object]> = [\n\t\t[\"global\", settingsManager.getGlobalSettings()],\n\t\t[\"project\", settingsManager.getProjectSettings()],\n\t];\n\tfor (const [scope, settings] of scopes) {\n\t\tconst names = findRemovedSettings(settings);\n\t\tif (names.length === 0) continue;\n\t\tdiagnostics.push({\n\t\t\ttype: \"warning\",\n\t\t\tmessage: removedSettingsMessage(scope, settingsManager.getSettingsPath(scope), names),\n\t\t});\n\t}\n\n\treturn diagnostics;\n}\n\n/**\n * Remove duplicate type/message diagnostics while preserving their first occurrence.\n * Startup and runtime settings managers can report the same file error.\n */\nexport function deduplicateDiagnostics(\n\tdiagnostics: readonly AgentSessionRuntimeDiagnostic[],\n): AgentSessionRuntimeDiagnostic[] {\n\tconst seen = new Set<string>();\n\treturn diagnostics.filter((diagnostic) => {\n\t\tconst key = `${diagnostic.type}\\0${diagnostic.message}`;\n\t\tif (seen.has(key)) return false;\n\t\tseen.add(key);\n\t\treturn true;\n\t});\n}\n"]}
|
|
@@ -336,6 +336,8 @@ export declare class SettingsManager {
|
|
|
336
336
|
getAgentDir(): string | undefined;
|
|
337
337
|
/** Merged policy settings (project replaces user arrays wholesale at this raw layer). */
|
|
338
338
|
getPolicySettings(): PoliciesSettings | undefined;
|
|
339
|
+
/** The file backing a scope, when the storage is file-backed. */
|
|
340
|
+
getSettingsPath(scope: SettingsScope): string | undefined;
|
|
339
341
|
getGlobalSettings(): Settings;
|
|
340
342
|
getProjectSettings(): Settings;
|
|
341
343
|
isProjectTrusted(): boolean;
|