headlesscode 1.0.2
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/ATTRIBUTION.md +53 -0
- package/CODE_OF_CONDUCT.md +130 -0
- package/CONTRIBUTING.md +107 -0
- package/LICENSE +202 -0
- package/README.md +486 -0
- package/SECURITY.md +211 -0
- package/bin/headlesscode.mjs +83 -0
- package/package.json +63 -0
- package/shared/prompts/review-mode-prompt-short.md +93 -0
- package/shared/prompts/review-mode-prompt.md +281 -0
- package/shared/rules-code/rules.md +22 -0
- package/shared/stacks/cpp/rules.md +30 -0
- package/shared/stacks/fastapi/rules.md +30 -0
- package/shared/stacks/javascript/rules.md +37 -0
- package/shared/stacks/postgresql/rules.md +31 -0
- package/shared/stacks/python/rules.md +35 -0
- package/shared/stacks/react/rules.md +11 -0
- package/shared/stacks/typescript/rules.md +10 -0
- package/src/budget/budget.ts +221 -0
- package/src/budget/concurrency.ts +126 -0
- package/src/budget/cost.ts +309 -0
- package/src/budget/index.ts +8 -0
- package/src/checkpoints/cli.ts +256 -0
- package/src/checkpoints/service.ts +227 -0
- package/src/cli.ts +1535 -0
- package/src/cloud/docker-provider.ts +334 -0
- package/src/cloud/provider.ts +300 -0
- package/src/codeintel/call-graph.ts +78 -0
- package/src/codeintel/find-references.ts +123 -0
- package/src/codeintel/go-to-definition.ts +193 -0
- package/src/codeintel/handlers.ts +190 -0
- package/src/codeintel/import-graph.ts +173 -0
- package/src/codeintel/outline.ts +180 -0
- package/src/codeintel/position.ts +77 -0
- package/src/codeintel/program.ts +350 -0
- package/src/codeintel/rename-symbol.ts +213 -0
- package/src/codeintel/tools.ts +280 -0
- package/src/codemap/build.ts +135 -0
- package/src/codemap/cli.ts +190 -0
- package/src/codemap/extract.ts +339 -0
- package/src/codemap/files.ts +236 -0
- package/src/codemap/fingerprint.ts +65 -0
- package/src/codemap/flows.ts +62 -0
- package/src/codemap/html.ts +451 -0
- package/src/codemap/lock.ts +80 -0
- package/src/codemap/types.ts +101 -0
- package/src/codesearch/airunner-embedder.ts +185 -0
- package/src/codesearch/chunk.ts +339 -0
- package/src/codesearch/cli.ts +223 -0
- package/src/codesearch/embedder.ts +332 -0
- package/src/codesearch/files.ts +280 -0
- package/src/codesearch/index.ts +469 -0
- package/src/codesearch/ollama-embedder.ts +205 -0
- package/src/codesearch/search.ts +141 -0
- package/src/codesearch/types.ts +100 -0
- package/src/config/mode-models.ts +218 -0
- package/src/dashboard/aggregate.ts +364 -0
- package/src/dashboard/chat-thread.ts +141 -0
- package/src/dashboard/checkpoints.ts +124 -0
- package/src/dashboard/cli.ts +193 -0
- package/src/dashboard/codemap.ts +44 -0
- package/src/dashboard/files.ts +121 -0
- package/src/dashboard/page.ts +2803 -0
- package/src/dashboard/self-improvement-metrics.ts +282 -0
- package/src/dashboard/server.ts +1103 -0
- package/src/dashboard/session-launch.ts +310 -0
- package/src/dashboard/timeline.ts +273 -0
- package/src/dashboard/tool-exec.ts +107 -0
- package/src/dashboard/trend-cli.ts +141 -0
- package/src/dashboard/trend.ts +413 -0
- package/src/decision-proxy/cli.ts +261 -0
- package/src/decision-proxy/proxy.ts +569 -0
- package/src/deploy/gate-cli.ts +147 -0
- package/src/deploy/gate.ts +254 -0
- package/src/engine/condense.ts +512 -0
- package/src/engine/events.ts +428 -0
- package/src/engine/handoff.ts +71 -0
- package/src/engine/lazy-tools.ts +160 -0
- package/src/engine/local-explore.ts +653 -0
- package/src/engine/logger.ts +96 -0
- package/src/engine/loop.ts +5517 -0
- package/src/engine/parser.ts +347 -0
- package/src/engine/prompt.ts +860 -0
- package/src/engine/reports.ts +47 -0
- package/src/engine/stacks.ts +448 -0
- package/src/engine/types.ts +291 -0
- package/src/engine/usage.ts +186 -0
- package/src/github/app-auth.ts +161 -0
- package/src/github/cli.ts +448 -0
- package/src/github/installations.ts +133 -0
- package/src/github/pr.ts +321 -0
- package/src/github/provision.ts +118 -0
- package/src/github/push.ts +122 -0
- package/src/index-util.ts +50 -0
- package/src/index.ts +81 -0
- package/src/init/cli.ts +248 -0
- package/src/init/gitignore.ts +74 -0
- package/src/llm/ollama.ts +308 -0
- package/src/llm/openrouter.ts +868 -0
- package/src/llm/preflight.ts +367 -0
- package/src/llm/transcript-capture.ts +84 -0
- package/src/memory/embed.ts +110 -0
- package/src/memory/index.ts +22 -0
- package/src/memory/local.ts +259 -0
- package/src/memory/summarizer.ts +283 -0
- package/src/memory/types.ts +153 -0
- package/src/memory/uwuchat.ts +157 -0
- package/src/migrate/cli.ts +115 -0
- package/src/orchestrator/analyze-cli.ts +104 -0
- package/src/orchestrator/auto-split.ts +206 -0
- package/src/orchestrator/cleanup.ts +1003 -0
- package/src/orchestrator/cli.ts +3571 -0
- package/src/orchestrator/cost-estimate.ts +564 -0
- package/src/orchestrator/cost-history-cli.ts +242 -0
- package/src/orchestrator/cost-history.ts +397 -0
- package/src/orchestrator/git-sync.ts +250 -0
- package/src/orchestrator/index.ts +153 -0
- package/src/orchestrator/log-analysis.ts +0 -0
- package/src/orchestrator/merge-check.ts +108 -0
- package/src/orchestrator/pipeline.ts +411 -0
- package/src/orchestrator/resume.ts +1940 -0
- package/src/orchestrator/reviewer.ts +503 -0
- package/src/orchestrator/split.ts +296 -0
- package/src/orchestrator/state.ts +542 -0
- package/src/orchestrator/status.ts +697 -0
- package/src/orchestrator/verification-gate.ts +134 -0
- package/src/orchestrator/watch.ts +898 -0
- package/src/permissions/commands.ts +1083 -0
- package/src/permissions/config.ts +241 -0
- package/src/permissions/index.ts +12 -0
- package/src/permissions/protected-files.ts +96 -0
- package/src/permissions/store-protection.ts +272 -0
- package/src/project-store.ts +648 -0
- package/src/projects/cli.ts +382 -0
- package/src/qa/qa.ts +487 -0
- package/src/tools/browser/handler.ts +346 -0
- package/src/tools/browser/service.ts +406 -0
- package/src/tools/browser/smoke.ts +78 -0
- package/src/tools/browser/tool.ts +99 -0
- package/src/tools/executor.ts +2575 -0
- package/src/tools/language-detect.ts +183 -0
- package/src/tools/output-summarizer.ts +369 -0
- package/src/tools/run-tests.ts +302 -0
- package/src/tools/set-indentation-tool.ts +49 -0
- package/src/tools/test-selection.ts +160 -0
- package/src/vendor/tests/smoke.ts +103 -0
- package/src/vendor/zoo-code/VENDOR-NOTES.md +213 -0
- package/src/vendor/zoo-code/shim/anthropic.ts +71 -0
- package/src/vendor/zoo-code/shim/openai.d.ts +60 -0
- package/src/vendor/zoo-code/shim/os-name.ts +18 -0
- package/src/vendor/zoo-code/shim/strip-bom.ts +14 -0
- package/src/vendor/zoo-code/shim/vscode.ts +76 -0
- package/src/vendor/zoo-code/src/core/config/CustomModesManager.ts +1015 -0
- package/src/vendor/zoo-code/src/core/diff/strategies/multi-search-replace.ts +670 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/capabilities.ts +46 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/custom-instructions.ts +559 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/index.ts +10 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/markdown-formatting.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/modes.ts +35 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/objective.ts +13 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/rules.ts +95 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/skills.ts +105 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/system-info.ts +30 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use-guidelines.ts +9 -0
- package/src/vendor/zoo-code/src/core/prompts/sections/tool-use.ts +7 -0
- package/src/vendor/zoo-code/src/core/prompts/system.ts +176 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/access_mcp_resource.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_diff.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/apply_patch.ts +61 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/ask_followup_question.ts +62 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/attempt_completion.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/codebase_search.ts +43 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/converters.ts +109 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit.ts +48 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/edit_file.ts +72 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/execute_command.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/generate_image.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/index.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/list_files.ts +41 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/mcp_server.ts +75 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/new_task.ts +39 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_command_output.ts +81 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/read_file.ts +169 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/run_slash_command.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_files.ts +50 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/search_replace.ts +51 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/skill.ts +33 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/switch_mode.ts +31 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/update_todo_list.ts +54 -0
- package/src/vendor/zoo-code/src/core/prompts/tools/native-tools/write_to_file.ts +40 -0
- package/src/vendor/zoo-code/src/core/prompts/types.ts +12 -0
- package/src/vendor/zoo-code/src/i18n/index.ts +19 -0
- package/src/vendor/zoo-code/src/integrations/misc/extract-text.ts +81 -0
- package/src/vendor/zoo-code/src/services/checkpoints/RepoPerTaskCheckpointService.ts +15 -0
- package/src/vendor/zoo-code/src/services/checkpoints/ShadowCheckpointService.ts +553 -0
- package/src/vendor/zoo-code/src/services/checkpoints/excludes.ts +212 -0
- package/src/vendor/zoo-code/src/services/checkpoints/index.ts +3 -0
- package/src/vendor/zoo-code/src/services/checkpoints/types.ts +35 -0
- package/src/vendor/zoo-code/src/services/code-index/manager.ts +19 -0
- package/src/vendor/zoo-code/src/services/mcp/McpHub.ts +36 -0
- package/src/vendor/zoo-code/src/services/roo-config/index.ts +441 -0
- package/src/vendor/zoo-code/src/services/search/file-search.ts +143 -0
- package/src/vendor/zoo-code/src/services/skills/SkillsManager.ts +20 -0
- package/src/vendor/zoo-code/src/shared/globalFileNames.ts +9 -0
- package/src/vendor/zoo-code/src/shared/language.ts +43 -0
- package/src/vendor/zoo-code/src/shared/modes.ts +257 -0
- package/src/vendor/zoo-code/src/shared/tools.ts +385 -0
- package/src/vendor/zoo-code/src/utils/fs.ts +39 -0
- package/src/vendor/zoo-code/src/utils/globalContext.ts +22 -0
- package/src/vendor/zoo-code/src/utils/json-schema.ts +16 -0
- package/src/vendor/zoo-code/src/utils/logging.ts +21 -0
- package/src/vendor/zoo-code/src/utils/mcp-name.ts +190 -0
- package/src/vendor/zoo-code/src/utils/object.ts +18 -0
- package/src/vendor/zoo-code/src/utils/path.ts +94 -0
- package/src/vendor/zoo-code/src/utils/shell.ts +376 -0
- package/src/vendor/zoo-code/src/utils/text-normalization.ts +99 -0
- package/src/vendor/zoo-code/types/global-settings.ts +19 -0
- package/src/vendor/zoo-code/types/index.ts +22 -0
- package/src/vendor/zoo-code/types/message.ts +375 -0
- package/src/vendor/zoo-code/types/mode.ts +241 -0
- package/src/vendor/zoo-code/types/todo.ts +19 -0
- package/src/vendor/zoo-code/types/tool-params.ts +116 -0
- package/src/vendor/zoo-code/types/tool.ts +67 -0
- package/src/vendor/zoo-code/types/vscode.ts +84 -0
- package/src/vision/describe.ts +242 -0
- package/src/vision/tool.ts +91 -0
- package/src/watcher/cli.ts +369 -0
- package/src/watcher/github.ts +304 -0
- package/src/watcher/index.ts +59 -0
- package/src/watcher/state.ts +254 -0
- package/src/watcher/watch.ts +562 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command allow/deny + protected-file permissions configuration for the
|
|
3
|
+
* headless harness.
|
|
4
|
+
*
|
|
5
|
+
* Resolution precedence (mirrors the flag/env/file convention established for
|
|
6
|
+
* the per-session budget in src/budget/cost.ts and src/cli.ts):
|
|
7
|
+
*
|
|
8
|
+
* CLI flags > env vars > central permissions.json > built-in defaults
|
|
9
|
+
*
|
|
10
|
+
* - CLI flags: `--allowed-commands` / `--denied-commands` / `--protected-files`
|
|
11
|
+
* / `--allow-protected-writes` (see src/cli.ts parseArgs)
|
|
12
|
+
* - env vars: `HEADLESSCODE_ALLOWED_COMMANDS` / `HEADLESSCODE_DENIED_COMMANDS`
|
|
13
|
+
* / `HEADLESSCODE_PROTECTED_FILES` /
|
|
14
|
+
* `HEADLESSCODE_ALLOW_PROTECTED_WRITES`
|
|
15
|
+
* - config file: the CENTRAL project store's `permissions.json`
|
|
16
|
+
* (`~/.local/share/headlesscode/projects/<key>/permissions.json` —
|
|
17
|
+
* see src/project-store.ts; keyed by the repo's git-common-dir, so
|
|
18
|
+
* worktrees share one policy with zero per-directory setup),
|
|
19
|
+
* auto-loaded when present (no flag needed). Schema:
|
|
20
|
+
* `{ "allowedCommands": string[], "deniedCommands": string[],
|
|
21
|
+
* "protectedFiles": string[], "allowProtectedWrites": boolean }`.
|
|
22
|
+
* A malformed file fails loudly — mirroring how
|
|
23
|
+
* `HEADLESSCODE_PRICING_JSON` is treated in src/budget/cost.ts —
|
|
24
|
+
* never silently weakens a policy.
|
|
25
|
+
*
|
|
26
|
+
* Deliberate defaults (documented in plans/permissions-parity.md):
|
|
27
|
+
* - `allowedCommands` defaults to `[]`, which the command decision layer
|
|
28
|
+
* (src/permissions/commands.ts) treats as "allow everything except the
|
|
29
|
+
* deny-list" — default-ALLOW, backward compatible with the pre-permissions
|
|
30
|
+
* harness. `deniedCommands` always applies even then.
|
|
31
|
+
* - `protectedFiles` defaults to `DEFAULT_PROTECTED_FILES` (secret/credential
|
|
32
|
+
* file patterns) — an unattended agent must not silently overwrite .env /
|
|
33
|
+
* keys / PEMs. Unlike command allow/deny, leaving this OFF by default would
|
|
34
|
+
* be unsafe.
|
|
35
|
+
* - `allowProtectedWrites` defaults to `false` (an explicit escape hatch only).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import * as fs from "node:fs"
|
|
39
|
+
import * as path from "node:path"
|
|
40
|
+
|
|
41
|
+
import { resolveProjectDataDir } from "../project-store.js"
|
|
42
|
+
import { DEFAULT_PROTECTED_FILES } from "./protected-files.js"
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Fully resolved permissions for one session / executor. Everything is
|
|
46
|
+
* concrete — no `undefined` fields — so enforcement code never has to guess.
|
|
47
|
+
*/
|
|
48
|
+
export interface PermissionsConfig {
|
|
49
|
+
/** Command prefixes that may run (empty = default-ALLOW; see commands.ts). */
|
|
50
|
+
allowedCommands: string[]
|
|
51
|
+
/** Command prefixes that may NEVER run (deny wins over allow). */
|
|
52
|
+
deniedCommands: string[]
|
|
53
|
+
/** Glob patterns of files that may not be written (see protected-files.ts). */
|
|
54
|
+
protectedFiles: string[]
|
|
55
|
+
/** Escape hatch: when true, the protected-files check is bypassed. */
|
|
56
|
+
allowProtectedWrites: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Raw (pre-resolution) overrides. Lists arrive as comma-separated strings —
|
|
61
|
+
* the same shape the CLI flags and env vars carry, so callers don't need to
|
|
62
|
+
* pre-split. `null`/absent means "no override at this layer".
|
|
63
|
+
*/
|
|
64
|
+
export interface PermissionsOverrides {
|
|
65
|
+
allowedCommands?: string | null
|
|
66
|
+
deniedCommands?: string | null
|
|
67
|
+
protectedFiles?: string | null
|
|
68
|
+
allowProtectedWrites?: boolean | null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The auto-loaded per-repo config file (schema above). */
|
|
72
|
+
export interface PermissionsFile {
|
|
73
|
+
allowedCommands?: string[]
|
|
74
|
+
deniedCommands?: string[]
|
|
75
|
+
protectedFiles?: string[]
|
|
76
|
+
allowProtectedWrites?: boolean
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Config file basename inside the central project data dir. */
|
|
80
|
+
export const PERMISSIONS_CONFIG_FILE = "permissions.json"
|
|
81
|
+
|
|
82
|
+
/** Absolute path of the central permissions.json for a workspace root. */
|
|
83
|
+
export function permissionsFilePath(workspaceRoot: string): string {
|
|
84
|
+
return path.join(resolveProjectDataDir(workspaceRoot), PERMISSIONS_CONFIG_FILE)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Split a comma-separated list (CLI flag / env var) into trimmed entries. */
|
|
88
|
+
export function parseCommaSeparated(raw: string | undefined): string[] | undefined {
|
|
89
|
+
if (raw === undefined || raw === null) {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
const entries = raw
|
|
93
|
+
.split(",")
|
|
94
|
+
.map((s) => s.trim())
|
|
95
|
+
.filter((s) => s.length > 0)
|
|
96
|
+
return entries.length > 0 ? entries : undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate an already-parsed permissions-file body (an object of
|
|
101
|
+
* {allowedCommands?, deniedCommands?, protectedFiles?, allowProtectedWrites?})
|
|
102
|
+
* and return the normalized `PermissionsFile`. Throws on malformed input —
|
|
103
|
+
* a broken policy must fail loudly, never silently fall back to weaker
|
|
104
|
+
* defaults. The file path is only used in error messages.
|
|
105
|
+
*
|
|
106
|
+
* Shared by BOTH the file loader below and the dashboard settings POST, so
|
|
107
|
+
* the HTTP save path can never drift from what the CLI/env path enforces.
|
|
108
|
+
*/
|
|
109
|
+
export function parsePermissionsFileBody(parsed: unknown, source: string): PermissionsFile {
|
|
110
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`permissions: '${source}' must be a JSON object of {allowedCommands, deniedCommands, protectedFiles, allowProtectedWrites}`,
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const out: PermissionsFile = {}
|
|
117
|
+
const obj = parsed as Record<string, unknown>
|
|
118
|
+
|
|
119
|
+
for (const key of ["allowedCommands", "deniedCommands", "protectedFiles"] as const) {
|
|
120
|
+
const value = obj[key]
|
|
121
|
+
if (value === undefined) {
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
125
|
+
throw new Error(`permissions: '${source}' field '${key}' must be an array of strings`)
|
|
126
|
+
}
|
|
127
|
+
out[key] = value as string[]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const allowProtectedWrites = obj.allowProtectedWrites
|
|
131
|
+
if (allowProtectedWrites !== undefined) {
|
|
132
|
+
if (typeof allowProtectedWrites !== "boolean") {
|
|
133
|
+
throw new Error(`permissions: '${source}' field 'allowProtectedWrites' must be a boolean`)
|
|
134
|
+
}
|
|
135
|
+
out.allowProtectedWrites = allowProtectedWrites
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Load the central `permissions.json` for a workspace when present.
|
|
143
|
+
* Returns `null` when the file does not exist (the common case). A
|
|
144
|
+
* present-but-malformed file throws — a broken policy must fail loudly, never
|
|
145
|
+
* silently fall back to weaker defaults. Falls back to the legacy
|
|
146
|
+
* `<workspaceRoot>/.headlesscode/permissions.json` only when the central file
|
|
147
|
+
* is absent AND the legacy one still exists (pre-migration grace — the
|
|
148
|
+
* migration in resolveProjectDataDir normally moves it before this is reached).
|
|
149
|
+
*/
|
|
150
|
+
export function loadPermissionsFile(workspaceRoot: string): PermissionsFile | null {
|
|
151
|
+
const filePath = permissionsFilePath(workspaceRoot)
|
|
152
|
+
let raw: string
|
|
153
|
+
try {
|
|
154
|
+
raw = fs.readFileSync(filePath, "utf-8")
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
157
|
+
const legacy = path.resolve(workspaceRoot, ".headlesscode", PERMISSIONS_CONFIG_FILE)
|
|
158
|
+
try {
|
|
159
|
+
raw = fs.readFileSync(legacy, "utf-8")
|
|
160
|
+
} catch {
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`permissions: cannot read permissions file '${filePath}': ${err instanceof Error ? err.message : String(err)}`,
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let parsed: unknown
|
|
171
|
+
try {
|
|
172
|
+
parsed = JSON.parse(raw)
|
|
173
|
+
} catch (err) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`permissions: invalid JSON in '${filePath}': ${err instanceof Error ? err.message : String(err)}`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return parsePermissionsFileBody(parsed, filePath)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Parse a truthy env flag ("1"/"true"/"yes"); undefined when unset. */
|
|
183
|
+
function envBoolean(name: string, env: NodeJS.ProcessEnv): boolean | undefined {
|
|
184
|
+
const raw = env[name]
|
|
185
|
+
if (raw === undefined || raw === "") {
|
|
186
|
+
return undefined
|
|
187
|
+
}
|
|
188
|
+
return raw === "1" || raw.toLowerCase() === "true" || raw.toLowerCase() === "yes"
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolve the effective permissions for a session:
|
|
193
|
+
* CLI-flag overrides > env vars > permissions.json > built-in defaults.
|
|
194
|
+
*
|
|
195
|
+
* `workspaceRoot` is required so the auto-loaded `<workspaceRoot>/.headlesscode/
|
|
196
|
+
* permissions.json` policy can participate. Callers that have no CLI flags (the
|
|
197
|
+
* reviewer/QA executors, which resolve at executor-construction time) simply
|
|
198
|
+
* omit `overrides` — env + config file + defaults still apply.
|
|
199
|
+
*/
|
|
200
|
+
export function resolvePermissions(options: {
|
|
201
|
+
workspaceRoot: string
|
|
202
|
+
overrides?: PermissionsOverrides
|
|
203
|
+
env?: NodeJS.ProcessEnv
|
|
204
|
+
}): PermissionsConfig {
|
|
205
|
+
const { workspaceRoot, overrides = {}, env = process.env } = options
|
|
206
|
+
const file = loadPermissionsFile(workspaceRoot)
|
|
207
|
+
|
|
208
|
+
const allowedCommands =
|
|
209
|
+
parseCommaSeparated(overrides.allowedCommands ?? undefined) ??
|
|
210
|
+
parseCommaSeparated(env.HEADLESSCODE_ALLOWED_COMMANDS) ??
|
|
211
|
+
file?.allowedCommands ??
|
|
212
|
+
[]
|
|
213
|
+
|
|
214
|
+
const deniedCommands =
|
|
215
|
+
parseCommaSeparated(overrides.deniedCommands ?? undefined) ??
|
|
216
|
+
parseCommaSeparated(env.HEADLESSCODE_DENIED_COMMANDS) ??
|
|
217
|
+
file?.deniedCommands ??
|
|
218
|
+
[]
|
|
219
|
+
|
|
220
|
+
const protectedFiles =
|
|
221
|
+
parseCommaSeparated(overrides.protectedFiles ?? undefined) ??
|
|
222
|
+
parseCommaSeparated(env.HEADLESSCODE_PROTECTED_FILES) ??
|
|
223
|
+
file?.protectedFiles ??
|
|
224
|
+
[...DEFAULT_PROTECTED_FILES]
|
|
225
|
+
|
|
226
|
+
// The escape hatch has no env-var requirement upstream, but honoring an
|
|
227
|
+
// env var keeps it usable for unattended workers (run-worker.sh sets
|
|
228
|
+
// policy via env). Precedence is the same chain.
|
|
229
|
+
const allowProtectedWrites =
|
|
230
|
+
overrides.allowProtectedWrites ??
|
|
231
|
+
envBoolean("HEADLESSCODE_ALLOW_PROTECTED_WRITES", env) ??
|
|
232
|
+
file?.allowProtectedWrites ??
|
|
233
|
+
false
|
|
234
|
+
|
|
235
|
+
return { allowedCommands, deniedCommands, protectedFiles, allowProtectedWrites }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Pretty-printed canonical serialization for the settings file. */
|
|
239
|
+
export function stringifyPermissionsFile(file: PermissionsFile): string {
|
|
240
|
+
return JSON.stringify(file, null, 2) + "\n"
|
|
241
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permissions module — command allow/deny + protected-file enforcement config
|
|
3
|
+
* and matching for the headless harness.
|
|
4
|
+
*
|
|
5
|
+
* - `config.ts` — resolution precedence (CLI flags > env > .headlesscode/permissions.json > defaults)
|
|
6
|
+
* - `commands.ts` — parseCommand + containsDangerousSubstitution + allow/deny decisions (ported from Zoo Code)
|
|
7
|
+
* - `protected-files.ts`— default protected patterns + glob matching
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export * from "./config.js"
|
|
11
|
+
export * from "./commands.js"
|
|
12
|
+
export * from "./protected-files.js"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protected-file glob matching for the headless harness.
|
|
3
|
+
*
|
|
4
|
+
* A write to any path matching the resolved protected-files list is refused
|
|
5
|
+
* (unless the `--allow-protected-writes` escape hatch is on). Simple glob
|
|
6
|
+
* matching, implemented in-repo — the vendored checkpoint excludes.ts
|
|
7
|
+
* (src/vendor/zoo-code/src/services/checkpoints/excludes.ts) only *builds*
|
|
8
|
+
* pattern lists that shadow-git feeds to git itself (via `git config
|
|
9
|
+
* core.excludesFile`-style config), it has no reusable TS matcher, and there
|
|
10
|
+
* is no glob/minimatch/picomatch dependency in this repo or node_modules.
|
|
11
|
+
*
|
|
12
|
+
* Matching semantics (deliberate, documented):
|
|
13
|
+
* - Paths are normalized to POSIX separators and matched against their path
|
|
14
|
+
* relative to the workspace root.
|
|
15
|
+
* - A pattern WITHOUT a slash matches the BASENAME at any depth — so `.env`
|
|
16
|
+
* matches `subdir/.env`, `*.key` matches `a/b/secret.key`, `id_rsa*`
|
|
17
|
+
* matches `config/id_rsa_backup`. This is the behavior the plan requires
|
|
18
|
+
* ("a bare filename pattern like `.env` should match `.env` at any depth").
|
|
19
|
+
* - A pattern WITH a slash is anchored to the workspace root (gitignore-style)
|
|
20
|
+
* and matches the full relative path — `config/credentials.key` matches
|
|
21
|
+
* `config/credentials.key` but not `other/config/credentials.key`.
|
|
22
|
+
* - A pattern ending in `/` protects a directory and everything under it.
|
|
23
|
+
* - `*` matches within one path segment (`[^/]*`), `**` matches across
|
|
24
|
+
* segments (`.*`), `?` matches one non-`/` character. Matching is
|
|
25
|
+
* case-sensitive (Linux glob convention).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Built-in protected-file default: secrets/credentials an unattended agent
|
|
29
|
+
* must never silently overwrite. Adjust/extend via config, but ship a real
|
|
30
|
+
* default. */
|
|
31
|
+
export const DEFAULT_PROTECTED_FILES: string[] = [".env", ".env.*", "*.pem", "*.key", "id_rsa*"]
|
|
32
|
+
|
|
33
|
+
/** Escape regex metacharacters except the glob wildcards we handle ourselves. */
|
|
34
|
+
function escapeRegExp(ch: string): string {
|
|
35
|
+
return "\\^$.[]{}()|+".includes(ch) ? `\\${ch}` : ch
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Convert `*`/`**`/`?` glob wildcards to a regex source (segment-aware). */
|
|
39
|
+
function globSource(pattern: string): string {
|
|
40
|
+
let out = ""
|
|
41
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
42
|
+
const ch = pattern[i]
|
|
43
|
+
if (ch === "*") {
|
|
44
|
+
if (pattern[i + 1] === "*") {
|
|
45
|
+
out += ".*"
|
|
46
|
+
i++
|
|
47
|
+
} else {
|
|
48
|
+
out += "[^/]*"
|
|
49
|
+
}
|
|
50
|
+
} else if (ch === "?") {
|
|
51
|
+
out += "[^/]"
|
|
52
|
+
} else {
|
|
53
|
+
out += escapeRegExp(ch)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Compile one protected-files pattern to a RegExp (see module header). */
|
|
60
|
+
export function patternToRegExp(pattern: string): RegExp {
|
|
61
|
+
// Directory pattern: "secrets/" protects the dir and everything under it.
|
|
62
|
+
if (pattern.endsWith("/")) {
|
|
63
|
+
const dir = globSource(pattern.slice(0, -1))
|
|
64
|
+
return new RegExp(`^${dir}(?:/.*)?$`)
|
|
65
|
+
}
|
|
66
|
+
// Anchored always. No-slash patterns are matched against the basename
|
|
67
|
+
// (findMatchingPattern picks the basename candidate), slash patterns against
|
|
68
|
+
// the full workspace-relative path — but both are full matches, never
|
|
69
|
+
// substring matches (so `.env` matches `.env` but not `.envs` or `.env.local`).
|
|
70
|
+
return new RegExp(`^${globSource(pattern)}$`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Return the first pattern in `patternList` that matches `relPath`, or `null`.
|
|
75
|
+
* `relPath` is the workspace-relative path (POSIX separators preferred; `\`
|
|
76
|
+
* is normalized).
|
|
77
|
+
*/
|
|
78
|
+
export function findMatchingPattern(relPath: string, patternList: string[]): string | null {
|
|
79
|
+
const normalized = relPath.replace(/\\/g, "/")
|
|
80
|
+
for (const pattern of patternList) {
|
|
81
|
+
if (pattern === "") {
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
const hasSlash = pattern.includes("/")
|
|
85
|
+
const candidate = hasSlash ? normalized : normalized.split("/").pop() ?? ""
|
|
86
|
+
if (candidate !== "" && patternToRegExp(pattern).test(candidate)) {
|
|
87
|
+
return pattern
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True when `relPath` matches any of the protected patterns. */
|
|
94
|
+
export function isProtectedPath(relPath: string, patternList: string[]): boolean {
|
|
95
|
+
return findMatchingPattern(relPath, patternList) !== null
|
|
96
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default-on protection for the shared central data store against destructive
|
|
3
|
+
* commands run through the `execute_command` TOOL.
|
|
4
|
+
*
|
|
5
|
+
* Background (plans/protect-shared-store-from-destructive-commands.md): a
|
|
6
|
+
* worker deleted the real `~/.local/share/headlesscode` with `rm -rf` as an
|
|
7
|
+
* ad-hoc verification step — three separate times. The command allow/deny
|
|
8
|
+
* system is deliberately opt-in (`permissions.json` / flags / env), so a
|
|
9
|
+
* session that configures nothing has ZERO default protection. The central
|
|
10
|
+
* store (src/project-store.ts) is a categorically different resource: it is
|
|
11
|
+
* SHARED across every project on the machine, so a single misbehaving
|
|
12
|
+
* workspace must not be able to destroy it. This check is therefore
|
|
13
|
+
* always-applied (even with an empty `deniedCommands` list) and NOT
|
|
14
|
+
* overridable by per-workspace permissions config — a config file a worker
|
|
15
|
+
* could write must never authorize deleting the shared store.
|
|
16
|
+
*
|
|
17
|
+
* Honest scope: this is PATTERN-BASED detection, not a hermetic sandbox. It
|
|
18
|
+
* catches recursive `rm` invocations (`rm -rf`, `rm -r`, `rm -fr`, `-R`,
|
|
19
|
+
* `--recursive`, in any flag order/bundle) whose resolved target is the store
|
|
20
|
+
* root, a PARENT of it, or a DESCENDANT of it (`rm -rf <store>/<subdir>`). It
|
|
21
|
+
* also follows symlinks when resolving the target (via `fs.realpathSync`,
|
|
22
|
+
* falling back to the lexical path when the target doesn't exist yet — e.g.
|
|
23
|
+
* `ln -s <store> /tmp/x && rm -rf /tmp/x` resolves through the symlink). It
|
|
24
|
+
* deliberately does NOT try to enumerate every way to destroy a file — a
|
|
25
|
+
* script, `python -c "shutil.rmtree(...)"`, `find ... -delete`, `command rm`,
|
|
26
|
+
* `sudo rm`, or a non-recursive `rm` of a single file inside the store are all
|
|
27
|
+
* out of scope for this version. It is a speed bump against the exact class
|
|
28
|
+
* of mistake that already happened, not a proof of safety. See SEC-5/SEC-6 in
|
|
29
|
+
* SECURITY.md for the full honest-scope writeup, including the residual
|
|
30
|
+
* cd-chain caveat (only a leading `cd <dir> &&` prefix chain updates the
|
|
31
|
+
* effective cwd used for later sub-commands; `cd` inside a subshell, via a
|
|
32
|
+
* variable, or via `pushd` is not tracked).
|
|
33
|
+
*
|
|
34
|
+
* Matching rule (per the plan): a command's target is refused when the
|
|
35
|
+
* RESOLVED path equals the store root, is an ancestor of it, or is a
|
|
36
|
+
* descendant of it. Resolution handles `~` / `~/`, `$VAR` / `${VAR}` env
|
|
37
|
+
* expansion, quoting, symlink following, and relative-vs-absolute paths
|
|
38
|
+
* (relative targets anchor on the command's working directory — the executor
|
|
39
|
+
* passes the resolved `cwd`, and `checkCommand` additionally tracks a leading
|
|
40
|
+
* `cd` chain across `&&`-joined sub-commands). A trailing `/*` glob is treated
|
|
41
|
+
* as the directory itself (the "delete the contents" idiom targets the same
|
|
42
|
+
* resource).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import * as fs from "node:fs"
|
|
46
|
+
import * as os from "node:os"
|
|
47
|
+
import * as path from "node:path"
|
|
48
|
+
|
|
49
|
+
import { projectStoreRoot } from "../project-store.js"
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Split a command string into shell argv words, honoring single quotes, double
|
|
53
|
+
* quotes and backslash escapes. `~` and `$VAR`/`${VAR}` are KEPT verbatim in
|
|
54
|
+
* the words so the path expander can process them (quote stripping only).
|
|
55
|
+
* Deliberately a minimal approximation of shell word splitting — good enough
|
|
56
|
+
* for the pattern this module detects, not a general shell parser.
|
|
57
|
+
*/
|
|
58
|
+
export function splitCommandWords(command: string): string[] {
|
|
59
|
+
const words: string[] = []
|
|
60
|
+
let current = ""
|
|
61
|
+
let inWord = false
|
|
62
|
+
let i = 0
|
|
63
|
+
while (i < command.length) {
|
|
64
|
+
const ch = command[i]
|
|
65
|
+
if (ch === "'") {
|
|
66
|
+
inWord = true
|
|
67
|
+
i++
|
|
68
|
+
while (i < command.length && command[i] !== "'") {
|
|
69
|
+
current += command[i++]
|
|
70
|
+
}
|
|
71
|
+
i++ // closing quote
|
|
72
|
+
} else if (ch === '"') {
|
|
73
|
+
inWord = true
|
|
74
|
+
i++
|
|
75
|
+
while (i < command.length && command[i] !== '"') {
|
|
76
|
+
if (command[i] === "\\" && i + 1 < command.length && '\\"$`'.includes(command[i + 1])) {
|
|
77
|
+
current += command[i + 1]
|
|
78
|
+
i += 2
|
|
79
|
+
} else {
|
|
80
|
+
current += command[i++]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
i++ // closing quote
|
|
84
|
+
} else if (ch === "\\") {
|
|
85
|
+
inWord = true
|
|
86
|
+
if (i + 1 < command.length) {
|
|
87
|
+
current += command[i + 1]
|
|
88
|
+
i += 2
|
|
89
|
+
} else {
|
|
90
|
+
i++
|
|
91
|
+
}
|
|
92
|
+
} else if (/\s/.test(ch)) {
|
|
93
|
+
if (inWord) {
|
|
94
|
+
words.push(current)
|
|
95
|
+
current = ""
|
|
96
|
+
inWord = false
|
|
97
|
+
}
|
|
98
|
+
i++
|
|
99
|
+
} else {
|
|
100
|
+
inWord = true
|
|
101
|
+
current += ch
|
|
102
|
+
i++
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (inWord) {
|
|
106
|
+
words.push(current)
|
|
107
|
+
}
|
|
108
|
+
return words
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* True when `words` describe a recursive `rm`: the program is `rm` and any
|
|
113
|
+
* option is `-r`/`-R` (in any short-flag bundle, e.g. `-rf`, `-fr`, `-Rf`) or
|
|
114
|
+
* the long `--recursive`. Flags are scanned across ALL words (GNU rm permutes
|
|
115
|
+
* options and operands), stopping at a literal `--`. Case-insensitive for the
|
|
116
|
+
* short flag (POSIX `-R` is the traditional spelling).
|
|
117
|
+
*/
|
|
118
|
+
export function isRecursiveDelete(words: string[]): boolean {
|
|
119
|
+
if (words.length === 0 || path.basename(words[0]) !== "rm") {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
for (let i = 1; i < words.length; i++) {
|
|
123
|
+
const w = words[i]
|
|
124
|
+
if (w === "--") {
|
|
125
|
+
break
|
|
126
|
+
}
|
|
127
|
+
if (w === "--recursive") {
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
if (w.startsWith("-") && w.length > 1 && !w.startsWith("--")) {
|
|
131
|
+
// Short-flag bundle: -r, -rf, -fr, -Rf, -rfi, ...
|
|
132
|
+
if (w.slice(1).toLowerCase().includes("r")) {
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return false
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The operand (path) words of a recursive `rm`: every word that is not an
|
|
142
|
+
* option, honoring a `--` end-of-options marker (everything after `--` is an
|
|
143
|
+
* operand even if it starts with `-`).
|
|
144
|
+
*/
|
|
145
|
+
export function deleteTargets(words: string[]): string[] {
|
|
146
|
+
const targets: string[] = []
|
|
147
|
+
let afterDoubleDash = false
|
|
148
|
+
for (let i = 1; i < words.length; i++) {
|
|
149
|
+
const w = words[i]
|
|
150
|
+
if (afterDoubleDash) {
|
|
151
|
+
targets.push(w)
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
if (w === "--") {
|
|
155
|
+
afterDoubleDash = true
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (w.startsWith("-") && w.length > 1) {
|
|
159
|
+
continue // option bundle / long option
|
|
160
|
+
}
|
|
161
|
+
targets.push(w)
|
|
162
|
+
}
|
|
163
|
+
return targets
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Expand a leading `~` / `~/` to the home directory. `~user` forms are left
|
|
167
|
+
* alone (they are not expanded by this module — vanishingly rare in the
|
|
168
|
+
* command class under guard, and resolving them needs a passwd lookup). */
|
|
169
|
+
export function expandHome(p: string): string {
|
|
170
|
+
if (p === "~") {
|
|
171
|
+
return os.homedir()
|
|
172
|
+
}
|
|
173
|
+
if (p.startsWith("~/")) {
|
|
174
|
+
return path.join(os.homedir(), p.slice(2))
|
|
175
|
+
}
|
|
176
|
+
return p
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Expand `$VAR` and `${VAR}` from `env` (default: process.env). An unset
|
|
180
|
+
* variable stays literal (the shell would turn it into an empty word, but
|
|
181
|
+
* keeping it literal errs toward refusing — safe direction). */
|
|
182
|
+
export function expandEnv(p: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
183
|
+
return p
|
|
184
|
+
.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name: string) => env[name] ?? m)
|
|
185
|
+
.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (m, name: string) => env[name] ?? m)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve a raw target word to an absolute path: env expansion, `~`
|
|
190
|
+
* expansion, then `path.resolve` against the command's working directory
|
|
191
|
+
* (relative targets anchor there). A trailing `/*` glob is dropped — deleting
|
|
192
|
+
* "the store's contents" via `rm -rf <store>/*` targets the store itself.
|
|
193
|
+
*/
|
|
194
|
+
export function resolveCommandTarget(target: string, workspaceRoot: string): string {
|
|
195
|
+
let expanded = expandEnv(expandHome(target))
|
|
196
|
+
if (expanded.endsWith("/*")) {
|
|
197
|
+
expanded = expanded.slice(0, -2)
|
|
198
|
+
}
|
|
199
|
+
return path.resolve(workspaceRoot, expanded)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Best-effort symlink resolution: `fs.realpathSync` when the path exists,
|
|
204
|
+
* otherwise the lexical path unchanged (a not-yet-existing target can't be
|
|
205
|
+
* resolved through a symlink, and that's fine — a `rm -rf` of a nonexistent
|
|
206
|
+
* path is a no-op anyway). Never throws.
|
|
207
|
+
*/
|
|
208
|
+
function tryRealpath(p: string): string {
|
|
209
|
+
try {
|
|
210
|
+
return fs.realpathSync(p)
|
|
211
|
+
} catch {
|
|
212
|
+
return p
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* True when the resolved `target` is the central store root, an ancestor of
|
|
218
|
+
* it, or a DESCENDANT of it. Checked both lexically (path.resolve) and
|
|
219
|
+
* against the symlink-resolved realpath — the same convention
|
|
220
|
+
* `resolveWithinWorkspace` documents for the harness's own path safety, plus
|
|
221
|
+
* realpath so `ln -s <store> /tmp/x && rm -rf /tmp/x` is caught too. The
|
|
222
|
+
* store root comes from project-store.ts's own resolver (projectStoreRoot),
|
|
223
|
+
* never a second copy of that path logic.
|
|
224
|
+
*/
|
|
225
|
+
export function isCentralStoreOrParent(target: string): boolean {
|
|
226
|
+
const storeRoot = projectStoreRoot()
|
|
227
|
+
const candidates = new Set([path.resolve(target), tryRealpath(path.resolve(target))])
|
|
228
|
+
for (const t of candidates) {
|
|
229
|
+
if (t === storeRoot) {
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
// Ancestor: storeRoot is inside t.
|
|
233
|
+
if (storeRoot.startsWith(t.endsWith(path.sep) ? t : t + path.sep)) {
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
// Descendant: t is inside storeRoot.
|
|
237
|
+
if (t.startsWith(storeRoot.endsWith(path.sep) ? storeRoot : storeRoot + path.sep)) {
|
|
238
|
+
return true
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** What a refused destructive command hit, for the model-facing message. */
|
|
245
|
+
export interface StoreDestructionRefusal {
|
|
246
|
+
/** The resolved absolute target that matched (store root or an ancestor). */
|
|
247
|
+
target: string
|
|
248
|
+
/** The protected central store root (projectStoreRoot()). */
|
|
249
|
+
storeRoot: string
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Check ONE sub-command (already split by parseCommand) for a recursive
|
|
254
|
+
* delete targeting the central store or a parent of it. Returns a refusal
|
|
255
|
+
* descriptor when it must be blocked, or `null` when it is not a
|
|
256
|
+
* store-targeting destructive command. `workspaceRoot` anchors relative
|
|
257
|
+
* targets (defaults to process.cwd()).
|
|
258
|
+
*/
|
|
259
|
+
export function checkCentralStoreDestruction(subCommand: string, workspaceRoot?: string): StoreDestructionRefusal | null {
|
|
260
|
+
const words = splitCommandWords(subCommand)
|
|
261
|
+
if (!isRecursiveDelete(words)) {
|
|
262
|
+
return null
|
|
263
|
+
}
|
|
264
|
+
const root = path.resolve(workspaceRoot ?? process.cwd())
|
|
265
|
+
for (const target of deleteTargets(words)) {
|
|
266
|
+
const resolved = resolveCommandTarget(target, root)
|
|
267
|
+
if (isCentralStoreOrParent(resolved)) {
|
|
268
|
+
return { target: resolved, storeRoot: projectStoreRoot() }
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return null
|
|
272
|
+
}
|