@guanlin4924/devflow-pi 0.1.94 → 0.1.96
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/README.md +34 -4
- package/node_modules/@playwright/test/LICENSE +202 -0
- package/node_modules/@playwright/test/NOTICE +5 -0
- package/node_modules/@playwright/test/README.md +318 -0
- package/node_modules/@playwright/test/cli.js +19 -0
- package/node_modules/@playwright/test/index.d.ts +18 -0
- package/node_modules/@playwright/test/index.js +17 -0
- package/node_modules/@playwright/test/index.mjs +18 -0
- package/node_modules/@playwright/test/package.json +35 -0
- package/node_modules/@playwright/test/reporter.d.ts +17 -0
- package/node_modules/@playwright/test/reporter.js +17 -0
- package/node_modules/@playwright/test/reporter.mjs +17 -0
- package/node_modules/playwright/LICENSE +202 -0
- package/node_modules/playwright/NOTICE +5 -0
- package/node_modules/playwright/README.md +318 -0
- package/node_modules/playwright/ThirdPartyNotices.txt +14 -0
- package/node_modules/playwright/cli.js +19 -0
- package/node_modules/playwright/index.d.ts +17 -0
- package/node_modules/playwright/index.js +17 -0
- package/node_modules/playwright/index.mjs +18 -0
- package/node_modules/playwright/jsx-runtime.js +42 -0
- package/node_modules/playwright/jsx-runtime.mjs +21 -0
- package/node_modules/playwright/lib/agents/agentParser.js +89 -0
- package/node_modules/playwright/lib/agents/copilot-setup-steps.yml +34 -0
- package/node_modules/playwright/lib/agents/generateAgents.js +395 -0
- package/node_modules/playwright/lib/agents/playwright-test-coverage.prompt.md +31 -0
- package/node_modules/playwright/lib/agents/playwright-test-generate.prompt.md +8 -0
- package/node_modules/playwright/lib/agents/playwright-test-generator.agent.md +88 -0
- package/node_modules/playwright/lib/agents/playwright-test-heal.prompt.md +6 -0
- package/node_modules/playwright/lib/agents/playwright-test-healer.agent.md +56 -0
- package/node_modules/playwright/lib/agents/playwright-test-plan.prompt.md +9 -0
- package/node_modules/playwright/lib/agents/playwright-test-planner.agent.md +74 -0
- package/node_modules/playwright/lib/cli/reportActions.js +78 -0
- package/node_modules/playwright/lib/cli/testActions.js +214 -0
- package/node_modules/playwright/lib/common/index.js +2923 -0
- package/node_modules/playwright/lib/common/index.js.txt +34 -0
- package/node_modules/playwright/lib/errorContext.js +129 -0
- package/node_modules/playwright/lib/globals.js +58 -0
- package/node_modules/playwright/lib/index.js +815 -0
- package/node_modules/playwright/lib/isomorphic.js +260 -0
- package/node_modules/playwright/lib/isomorphic.js.txt +9 -0
- package/node_modules/playwright/lib/loader/loaderProcessEntry.js +34 -0
- package/node_modules/playwright/lib/loader/loaderProcessEntry.js.txt +9 -0
- package/node_modules/playwright/lib/matchers/expect.js +13490 -0
- package/node_modules/playwright/lib/matchers/expect.js.LICENSE +640 -0
- package/node_modules/playwright/lib/matchers/expect.js.txt +67 -0
- package/node_modules/playwright/lib/mcp/test/browserBackend.js +135 -0
- package/node_modules/playwright/lib/mcp/test/generatorTools.js +122 -0
- package/node_modules/playwright/lib/mcp/test/plannerTools.js +150 -0
- package/node_modules/playwright/lib/mcp/test/seed.js +82 -0
- package/node_modules/playwright/lib/mcp/test/streams.js +44 -0
- package/node_modules/playwright/lib/mcp/test/testBackend.js +99 -0
- package/node_modules/playwright/lib/mcp/test/testContext.js +306 -0
- package/node_modules/playwright/lib/mcp/test/testTool.js +30 -0
- package/node_modules/playwright/lib/mcp/test/testTools.js +98 -0
- package/node_modules/playwright/lib/package.js +47 -0
- package/node_modules/playwright/lib/program.js +241 -0
- package/node_modules/playwright/lib/runner/index.js +8527 -0
- package/node_modules/playwright/lib/runner/index.js.txt +61 -0
- package/node_modules/playwright/lib/transform/babelBundle.js +72274 -0
- package/node_modules/playwright/lib/transform/babelBundle.js.LICENSE +2384 -0
- package/node_modules/playwright/lib/transform/babelBundle.js.txt +325 -0
- package/node_modules/playwright/lib/transform/esmLoader.js +8054 -0
- package/node_modules/playwright/lib/transform/esmLoader.js.LICENSE +355 -0
- package/node_modules/playwright/lib/transform/esmLoader.js.txt +60 -0
- package/node_modules/playwright/lib/util.js +373 -0
- package/node_modules/playwright/lib/worker/workerProcessEntry.js +1861 -0
- package/node_modules/playwright/lib/worker/workerProcessEntry.js.txt +19 -0
- package/node_modules/playwright/package.json +55 -0
- package/node_modules/playwright/test.d.ts +18 -0
- package/node_modules/playwright/test.js +24 -0
- package/node_modules/playwright/test.mjs +35 -0
- package/node_modules/playwright/types/test.d.ts +11074 -0
- package/node_modules/playwright/types/testReporter.d.ts +950 -0
- package/node_modules/playwright-core/LICENSE +202 -0
- package/node_modules/playwright-core/NOTICE +5 -0
- package/node_modules/playwright-core/README.md +3 -0
- package/node_modules/playwright-core/ThirdPartyNotices.txt +13 -0
- package/node_modules/playwright-core/bin/install_media_pack.ps1 +5 -0
- package/node_modules/playwright-core/bin/install_webkit_wsl.ps1 +33 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_linux.sh +42 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_mac.sh +13 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_beta_win.ps1 +24 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_linux.sh +42 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_mac.sh +12 -0
- package/node_modules/playwright-core/bin/reinstall_chrome_stable_win.ps1 +24 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_beta_win.ps1 +23 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_dev_win.ps1 +23 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_linux.sh +48 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_mac.sh +11 -0
- package/node_modules/playwright-core/bin/reinstall_msedge_stable_win.ps1 +24 -0
- package/node_modules/playwright-core/browsers.json +52 -0
- package/node_modules/playwright-core/cli.js +21 -0
- package/node_modules/playwright-core/index.d.ts +17 -0
- package/node_modules/playwright-core/index.js +17 -0
- package/node_modules/playwright-core/index.mjs +28 -0
- package/node_modules/playwright-core/lib/bootstrap.js +88 -0
- package/node_modules/playwright-core/lib/coreBundle.js +75813 -0
- package/node_modules/playwright-core/lib/entry/cliDaemon.js +5 -0
- package/node_modules/playwright-core/lib/entry/dashboardApp.js +3 -0
- package/node_modules/playwright-core/lib/entry/mcp.js +10 -0
- package/node_modules/playwright-core/lib/entry/oopBrowserDownload.js +3 -0
- package/node_modules/playwright-core/lib/package.js +50 -0
- package/node_modules/playwright-core/lib/server/chromium/appIcon.png +0 -0
- package/node_modules/playwright-core/lib/server/electron/loader.js +116 -0
- package/node_modules/playwright-core/lib/serverRegistry.js +1939 -0
- package/node_modules/playwright-core/lib/serverRegistry.js.LICENSE +65 -0
- package/node_modules/playwright-core/lib/tools/cli-client/channelSessions.js +141 -0
- package/node_modules/playwright-core/lib/tools/cli-client/cli.js +6 -0
- package/node_modules/playwright-core/lib/tools/cli-client/help.json +721 -0
- package/node_modules/playwright-core/lib/tools/cli-client/minimist.js +128 -0
- package/node_modules/playwright-core/lib/tools/cli-client/output.js +351 -0
- package/node_modules/playwright-core/lib/tools/cli-client/program.js +418 -0
- package/node_modules/playwright-core/lib/tools/cli-client/registry.js +180 -0
- package/node_modules/playwright-core/lib/tools/cli-client/session.js +259 -0
- package/node_modules/playwright-core/lib/tools/dashboard/appIcon.png +0 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/SKILL.md +425 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/element-attributes.md +23 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/playwright-tests.md +39 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/request-mocking.md +87 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/running-code.md +241 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/session-management.md +225 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/storage-state.md +275 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/test-generation.md +433 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/tracing.md +139 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-cli/references/video-recording.md +143 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/SKILL.md +136 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/gallery-spec.md +144 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/migration.md +89 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/react.md +69 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/typing.md +173 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-component-testing/references/vue.md +77 -0
- package/node_modules/playwright-core/lib/tools/skills/playwright-trace/SKILL.md +174 -0
- package/node_modules/playwright-core/lib/tools/utils/extension.js +112 -0
- package/node_modules/playwright-core/lib/tools/utils/socketConnection.js +108 -0
- package/node_modules/playwright-core/lib/utilsBundle.js +85357 -0
- package/node_modules/playwright-core/lib/utilsBundle.js.LICENSE +1890 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule--QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codeMirrorModule-CQ8RZGBm.js +32 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/codicon-DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-1bWoP6pv.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-beta-k3eOH_eK.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/firefox-nightly-Cp5nfeDT.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/index-AwgwcYie.css +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/index-hRvO4yU2.js +12 -0
- package/node_modules/playwright-core/lib/vite/dashboard/assets/safari-na3_-uQk.svg +1 -0
- package/node_modules/playwright-core/lib/vite/dashboard/index.html +29 -0
- package/node_modules/playwright-core/lib/vite/dashboard/playwright-logo.svg +24 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/index.html +16 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/report.css +2 -0
- package/node_modules/playwright-core/lib/vite/htmlReport/report.js +32 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule--QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codeMirrorModule-CVQWJZAA.js +32 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/codicon-DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/index-BZpYJZ-P.css +1 -0
- package/node_modules/playwright-core/lib/vite/recorder/assets/index-hrlLqLtq.js +129 -0
- package/node_modules/playwright-core/lib/vite/recorder/index.html +29 -0
- package/node_modules/playwright-core/lib/vite/recorder/playwright-logo.svg +9 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/codeMirrorModule-BbkfBe3n.js +32 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/defaultSettingsView-Ds6CBOo0.js +182 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/urlMatch-L3liM589.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/assets/xtermModule-DywYcAf8.js +7 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/codeMirrorModule.-QdMvsKi.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/codicon.DCmgc-ay.ttf +0 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/defaultSettingsView.Bqk9acqE.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.B4cLoZK3.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.B_TqY17P.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/index.html +44 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/manifest.webmanifest +16 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/playwright-logo.svg +9 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.B_Jk1wbt.js +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/snapshot.html +10 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js +4 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CU5KtEkS.js +5 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.CyMfwkXJ.css +1 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/uiMode.html +18 -0
- package/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.kHJ-D0s7.css +1 -0
- package/node_modules/playwright-core/lib/webp_codec.LICENSE +173 -0
- package/node_modules/playwright-core/lib/webp_codec.wasm +0 -0
- package/node_modules/playwright-core/lib/xdg-open +1267 -0
- package/node_modules/playwright-core/package.json +34 -0
- package/node_modules/playwright-core/types/protocol.d.ts +24645 -0
- package/node_modules/playwright-core/types/structs.d.ts +52 -0
- package/node_modules/playwright-core/types/types.d.ts +26632 -0
- package/package.json +13 -2
- package/plugins/devflow-core/extensions/devflow-core.ts +432 -7
- package/plugins/devflow-core/runtime/hooks/hook_common.py +514 -0
- package/plugins/devflow-core/runtime/hooks/index_gate.py +47 -16
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/acceptance_test_runner.py +488 -71
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +51 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_pins.py +433 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/approval_store.py +137 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_coverage.py +87 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_draft.py +53 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/dsl_v1.py +318 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/environment_profile.py +134 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_surface.py +138 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/playwright_adapter.py +515 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/tc_hash.py +132 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/test_command_policy.py +102 -8
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +13 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +13 -34
- package/plugins/devflow-core/skills/workflow-pipeline/SKILL.md +3 -1
- package/plugins/devflow-core/skills/workflow-pipeline/references/pipeline-flow.md +68 -140
- package/plugins/devflow-core/skills/workflow-project-context/scripts/validate_initialization_database.py +1 -1
- package/plugins/devflow-implementation/skills/workflow-execute-phase/references/execute-phase.md +3 -1
- package/plugins/devflow-intel/extensions/devflow-intel.ts +1 -1
- package/plugins/devflow-intel/skills/workflow-code-index/scripts/code_index.py +23 -4
- package/plugins/devflow-quality/runtime/playwright/devflow-test-dsl-v1.schema.json +872 -0
- package/plugins/devflow-quality/runtime/playwright/playwright.config.mjs +46 -0
- package/plugins/devflow-quality/runtime/playwright/reporter.mjs +311 -0
- package/plugins/devflow-quality/runtime/playwright/runner.spec.mjs +1308 -0
- package/plugins/devflow-quality/runtime/playwright/runtime-manifest.json +11 -0
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +3 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/SKILL.md +71 -58
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/api-mainflow-rules.md +20 -15
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/codebase-discovery.md +3 -1
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/intermediate-format.md +115 -192
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/mainflow-selection.md +27 -23
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/project-dsl-contract.md +78 -153
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/references/ui-mainflow-rules.md +36 -83
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/compile_dsl.py +497 -556
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/export_legacy_testagent.py +647 -0
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/normalize_case_payloads.py +10 -3
- package/plugins/devflow-quality/skills/workflow-generate-mainflow-cases/scripts/validate_case_payloads.py +165 -275
- package/plugins/devflow-quality/skills/workflow-human-acceptance/SKILL.md +63 -0
- package/plugins/devflow-quality/skills/workflow-release-closeout/SKILL.md +4 -4
- package/plugins/devflow-quality/skills/workflow-run-acceptance-tests/SKILL.md +17 -9
- package/src/approval-store.ts +340 -0
- package/src/cli.ts +10 -0
- package/src/constants.ts +1 -1
- package/src/doctor.ts +268 -2
- package/src/initialization-store.ts +48 -1
- package/src/mcp/auth.ts +103 -0
- package/src/mcp/core.ts +102 -0
- package/src/mcp/server.ts +193 -0
- package/src/mcp/types.ts +5 -0
- package/src/paths.ts +12 -1
- package/src/project-context.ts +24 -1
- package/templates/omp/hook-config.json +6 -0
|
@@ -442,6 +442,520 @@ def tool_requests_protected_source(
|
|
|
442
442
|
return False, []
|
|
443
443
|
|
|
444
444
|
|
|
445
|
+
# Core control store protection (approval records live in control.db). This is
|
|
446
|
+
# misuse prevention, not a defence against a hostile same-user process. It is
|
|
447
|
+
# always on: neither DEVFLOW_SOFT_GATES, the policy mode nor hook-config can
|
|
448
|
+
# disable it; hook-config may only add extra protected path globs.
|
|
449
|
+
CONTROL_STORE_NAME_RE = re.compile(r"^control\.db(?:$|[-.])", re.IGNORECASE)
|
|
450
|
+
CANONICAL_ACCEPTANCE_RE = re.compile(
|
|
451
|
+
r"(?:^|/)\.devflow/planning/test-runs/[^/]+/acceptance-results(?:\.meta)?\.json$",
|
|
452
|
+
re.IGNORECASE,
|
|
453
|
+
)
|
|
454
|
+
CANONICAL_ACCEPTANCE_COMMAND_RE = re.compile(
|
|
455
|
+
r"\.devflow/planning/test-runs/(?:[^\s/'\"`]+|\$\{?[^\s}/]+\}?)/acceptance-results(?:\.meta)?\.json\b",
|
|
456
|
+
re.IGNORECASE,
|
|
457
|
+
)
|
|
458
|
+
_CONTROL_TABLE = r"[\"'`\[]?(?:main\.)?(?:approval_proposals|approvals|authority_state)\b[\"'`\]]?"
|
|
459
|
+
APPROVAL_SQL_RE = re.compile(
|
|
460
|
+
r"\b(?:insert\s+(?:or\s+\w+\s+)?into|replace\s+into|delete\s+from|drop\s+table(?:\s+if\s+exists)?|alter\s+table)\s+"
|
|
461
|
+
+ _CONTROL_TABLE
|
|
462
|
+
+ r"|\bupdate\s+(?:or\s+\w+\s+)?"
|
|
463
|
+
+ _CONTROL_TABLE
|
|
464
|
+
+ r"\s+set\b",
|
|
465
|
+
re.IGNORECASE,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def devflow_home() -> Path:
|
|
470
|
+
"""DEVFLOW_HOME rule shared with src/paths.ts: unset/empty/whitespace-only -> ~/.devflow-pi,
|
|
471
|
+
otherwise expand a leading ``~`` and make absolute (no symlink resolution, existence not required)."""
|
|
472
|
+
configured = os.environ.get("DEVFLOW_HOME")
|
|
473
|
+
if configured is None or not configured.strip():
|
|
474
|
+
return Path(os.path.abspath(str(Path.home() / ".devflow-pi")))
|
|
475
|
+
if configured == "~" or configured.startswith("~/"):
|
|
476
|
+
configured = str(Path.home()) + configured[1:]
|
|
477
|
+
return Path(os.path.abspath(configured))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _raw_tool_paths(event: dict[str, Any]) -> list[str]:
|
|
481
|
+
tool_input = event.get("tool_input", {})
|
|
482
|
+
values = list(_walk_path_values(tool_input))
|
|
483
|
+
if isinstance(tool_input, dict):
|
|
484
|
+
for key in ("patch", "input"):
|
|
485
|
+
patch = tool_input.get(key)
|
|
486
|
+
if isinstance(patch, str):
|
|
487
|
+
values.extend(re.findall(r"^\*\*\* (?:Add|Update|Delete|Move) (?:File|to):\s*(.+)$", patch, re.MULTILINE))
|
|
488
|
+
elif isinstance(tool_input, str):
|
|
489
|
+
values.extend(re.findall(r"^\*\*\* (?:Add|Update|Delete|Move) (?:File|to):\s*(.+)$", tool_input, re.MULTILINE))
|
|
490
|
+
return [str(value).strip().strip("'\"") for value in values if str(value).strip()]
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _extra_control_store_globs(root: Path) -> list[str]:
|
|
494
|
+
config = load_hook_config(root).get("gates", {})
|
|
495
|
+
section = config.get("control_store", {}) if isinstance(config, dict) else {}
|
|
496
|
+
extra = section.get("extra_protected_paths", []) if isinstance(section, dict) else []
|
|
497
|
+
return [str(item) for item in extra if isinstance(item, str) and item.strip()] if isinstance(extra, list) else []
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _under(item: Path, base: Path) -> bool:
|
|
501
|
+
try:
|
|
502
|
+
item.relative_to(base)
|
|
503
|
+
return True
|
|
504
|
+
except ValueError:
|
|
505
|
+
return False
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _data_home_project_roots() -> list[Path]:
|
|
509
|
+
"""Every spelling of the Devflow ``projects`` tree (configured home, default home, symlink-resolved)."""
|
|
510
|
+
homes = [devflow_home(), Path(os.path.abspath(str(Path.home() / ".devflow-pi")))]
|
|
511
|
+
roots: list[Path] = []
|
|
512
|
+
for home in homes:
|
|
513
|
+
for base in (home, Path(os.path.realpath(str(home)))):
|
|
514
|
+
root = base / "projects"
|
|
515
|
+
if root not in roots:
|
|
516
|
+
roots.append(root)
|
|
517
|
+
return roots
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def is_pure_read_tool(event: dict[str, Any]) -> bool:
|
|
521
|
+
"""True for file-reader tools (Read/Grep/Glob/...) that cannot mutate anything."""
|
|
522
|
+
name = tool_name(event)
|
|
523
|
+
return (
|
|
524
|
+
any(marker in name for marker in READ_TOOL_MARKERS)
|
|
525
|
+
and not is_mutation_tool(event)
|
|
526
|
+
and not is_command_tool(event)
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def path_is_control_store(
|
|
531
|
+
raw: str, cwd: Path, extra_globs: list[str] | None = None, allow_acceptance_read: bool = False
|
|
532
|
+
) -> bool:
|
|
533
|
+
"""``allow_acceptance_read`` opens canonical acceptance-results*.json for pure read tools only;
|
|
534
|
+
control.db, the data-home projects tree and extra globs stay denied for every access."""
|
|
535
|
+
candidate = Path(raw).expanduser()
|
|
536
|
+
if not candidate.is_absolute():
|
|
537
|
+
candidate = cwd / candidate
|
|
538
|
+
lexical = Path(os.path.normpath(str(candidate)))
|
|
539
|
+
resolved = Path(os.path.realpath(str(candidate)))
|
|
540
|
+
project_roots = _data_home_project_roots()
|
|
541
|
+
for item in (lexical, resolved):
|
|
542
|
+
if not allow_acceptance_read and CANONICAL_ACCEPTANCE_RE.search(item.as_posix()):
|
|
543
|
+
return True
|
|
544
|
+
if CONTROL_STORE_NAME_RE.match(item.name):
|
|
545
|
+
return True
|
|
546
|
+
if any(_under(item, root) for root in project_roots):
|
|
547
|
+
return True
|
|
548
|
+
if any(fnmatch.fnmatch(item.as_posix(), pattern) for pattern in extra_globs or []):
|
|
549
|
+
return True
|
|
550
|
+
return False
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# Programs that can open a SQLite file (or run arbitrary code that can) from their arguments.
|
|
554
|
+
DB_CAPABLE_PROGRAM_RE = re.compile(
|
|
555
|
+
r"^(?:sqlite3?|python[\d.]*|pypy[\d.]*|bun|bunx|node|deno|ruby|perl|php|lua|litecli|duckdb|datasette|sqlite-utils|sqlitebrowser)$",
|
|
556
|
+
re.IGNORECASE,
|
|
557
|
+
)
|
|
558
|
+
# Programs that read/copy/delete their file arguments, so a bare ``control.db`` operand is a real file target.
|
|
559
|
+
FILE_OPERAND_PROGRAMS = {
|
|
560
|
+
"cat", "less", "more", "head", "tail", "strings", "xxd", "od", "hexdump", "file", "stat", "cp", "mv", "rm",
|
|
561
|
+
"tee", "ln", "dd", "install", "touch", "truncate", "rsync", "scp", "sha256sum", "shasum", "md5sum", "md5",
|
|
562
|
+
"cksum", "base64", "open", "tar", "zip", "gzip", "gunzip", "bzip2", "xz", "unlink", "shred", "chmod", "chown",
|
|
563
|
+
}
|
|
564
|
+
WRAPPER_PROGRAMS = {"sudo", "env", "command", "time", "nohup", "exec", "nice", "xargs", "builtin", "stdbuf", "timeout"}
|
|
565
|
+
SHELL_PROGRAMS = {"sh", "bash", "zsh", "dash", "ksh", "fish"}
|
|
566
|
+
CONTROL_OPERATORS = {";", "&&", "||", "|", "&", "|&", "(", ")", "\n"}
|
|
567
|
+
REDIRECT_OPERATORS = {">", ">>", ">|", "<", "<>", "&>", ">&", "<&", "&>>"}
|
|
568
|
+
ASSIGNMENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _split_unquoted_newlines(command: str) -> str:
|
|
572
|
+
out: list[str] = []
|
|
573
|
+
quote = ""
|
|
574
|
+
escaped = False
|
|
575
|
+
for ch in command:
|
|
576
|
+
if escaped:
|
|
577
|
+
escaped = False
|
|
578
|
+
elif ch == "\\" and quote != "'":
|
|
579
|
+
escaped = True
|
|
580
|
+
elif quote:
|
|
581
|
+
if ch == quote:
|
|
582
|
+
quote = ""
|
|
583
|
+
elif ch in "'\"":
|
|
584
|
+
quote = ch
|
|
585
|
+
elif ch == "\n":
|
|
586
|
+
out.append(" ; ")
|
|
587
|
+
continue
|
|
588
|
+
out.append(ch)
|
|
589
|
+
return "".join(out)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _shell_tokens(command: str) -> list[str]:
|
|
593
|
+
import shlex
|
|
594
|
+
|
|
595
|
+
prepared = _split_unquoted_newlines(command)
|
|
596
|
+
try:
|
|
597
|
+
lexer = shlex.shlex(prepared, posix=True, punctuation_chars=True)
|
|
598
|
+
lexer.whitespace_split = True
|
|
599
|
+
lexer.commenters = ""
|
|
600
|
+
return list(lexer)
|
|
601
|
+
except ValueError:
|
|
602
|
+
return [item.strip("'\"") for item in prepared.split()]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _expand_shell_path(token: str) -> str:
|
|
606
|
+
home = devflow_home().as_posix()
|
|
607
|
+
user_home = str(Path.home())
|
|
608
|
+
for name, value in (("DEVFLOW_HOME", home), ("HOME", user_home)):
|
|
609
|
+
token = re.sub(r"\$\{" + name + r"(?::-[^}]*)?\}|\$" + name + r"\b", lambda _m, v=value: v, token)
|
|
610
|
+
if token == "~" or token.startswith("~/"):
|
|
611
|
+
token = user_home + token[1:]
|
|
612
|
+
return token
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _token_is_control_target(token: str, cwd: Path, *, bare_name_is_target: bool) -> bool:
|
|
616
|
+
if not token or any(ch.isspace() for ch in token):
|
|
617
|
+
return False
|
|
618
|
+
if token.startswith("-") and "=" in token:
|
|
619
|
+
token = token.split("=", 1)[1]
|
|
620
|
+
elif token.startswith("-"):
|
|
621
|
+
return False
|
|
622
|
+
expanded = _expand_shell_path(token).replace("\\", "/")
|
|
623
|
+
candidate = Path(expanded)
|
|
624
|
+
if not candidate.is_absolute():
|
|
625
|
+
candidate = cwd / candidate
|
|
626
|
+
lexical = Path(os.path.normpath(str(candidate)))
|
|
627
|
+
resolved = Path(os.path.realpath(str(candidate)))
|
|
628
|
+
project_roots = _data_home_project_roots()
|
|
629
|
+
for item in (lexical, resolved):
|
|
630
|
+
if any(_under(item, root) for root in project_roots):
|
|
631
|
+
return True
|
|
632
|
+
if ".devflow-pi/projects" in expanded:
|
|
633
|
+
return True
|
|
634
|
+
for item in (lexical, resolved):
|
|
635
|
+
if not CONTROL_STORE_NAME_RE.match(item.name):
|
|
636
|
+
continue
|
|
637
|
+
# ``rg control.db src/`` names a pattern, not a file: a bare name only counts when it is
|
|
638
|
+
# a real file, is redirected/handed to a file-operating program, or carries a directory part.
|
|
639
|
+
if "/" in expanded or bare_name_is_target or item.exists() or resolved.exists():
|
|
640
|
+
return True
|
|
641
|
+
return False
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _segments(tokens: list[str]) -> list[list[str]]:
|
|
645
|
+
segments: list[list[str]] = [[]]
|
|
646
|
+
for token in tokens:
|
|
647
|
+
if token in CONTROL_OPERATORS:
|
|
648
|
+
segments.append([])
|
|
649
|
+
else:
|
|
650
|
+
segments[-1].append(token)
|
|
651
|
+
return [segment for segment in segments if segment]
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _analyze_command(command: str, cwd: Path, depth: int = 0) -> bool:
|
|
655
|
+
tokens = _shell_tokens(command)
|
|
656
|
+
for segment in _segments(tokens):
|
|
657
|
+
index = 0
|
|
658
|
+
while index < len(segment) and (ASSIGNMENT_RE.match(segment[index]) or segment[index] in WRAPPER_PROGRAMS):
|
|
659
|
+
index += 1
|
|
660
|
+
if index >= len(segment):
|
|
661
|
+
continue
|
|
662
|
+
program = os.path.basename(segment[index]).lower()
|
|
663
|
+
operands = segment[index + 1 :]
|
|
664
|
+
db_program = bool(DB_CAPABLE_PROGRAM_RE.match(program))
|
|
665
|
+
if program in SHELL_PROGRAMS and depth < 3:
|
|
666
|
+
for pos, operand in enumerate(operands):
|
|
667
|
+
if operand.startswith("-") and "c" in operand[1:] and pos + 1 < len(operands):
|
|
668
|
+
if _analyze_command(operands[pos + 1], cwd, depth + 1):
|
|
669
|
+
return True
|
|
670
|
+
previous = ""
|
|
671
|
+
for operand in [*segment[:index], *operands]:
|
|
672
|
+
after_redirect = previous in REDIRECT_OPERATORS or bool(re.match(r"^\d*(?:>>?|<)$", previous))
|
|
673
|
+
bare_ok = db_program or after_redirect or program in FILE_OPERAND_PROGRAMS
|
|
674
|
+
if operand not in REDIRECT_OPERATORS:
|
|
675
|
+
if db_program and (
|
|
676
|
+
re.search(r"control\.db", operand, re.IGNORECASE) or ".devflow-pi/projects" in operand
|
|
677
|
+
):
|
|
678
|
+
return True
|
|
679
|
+
if _token_is_control_target(operand, cwd, bare_name_is_target=bare_ok):
|
|
680
|
+
return True
|
|
681
|
+
glued = re.sub(r"^\d*[<>]+", "", operand)
|
|
682
|
+
if glued != operand and _token_is_control_target(glued, cwd, bare_name_is_target=True):
|
|
683
|
+
return True
|
|
684
|
+
previous = operand
|
|
685
|
+
return False
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def command_touches_control_store(command: str, cwd: Path | None = None) -> bool:
|
|
689
|
+
"""Deny shell commands that target the real control store or write approval tables.
|
|
690
|
+
|
|
691
|
+
Merely mentioning the text ``control.db`` (rg patterns, commit messages, echo) is allowed;
|
|
692
|
+
this is a mistake-prevention guard, not a sandbox."""
|
|
693
|
+
normalized = command.replace("\\", "/")
|
|
694
|
+
if APPROVAL_SQL_RE.search(normalized) or CANONICAL_ACCEPTANCE_COMMAND_RE.search(normalized):
|
|
695
|
+
return True
|
|
696
|
+
return _analyze_command(command, cwd or Path.cwd())
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def tool_touches_control_store(event: dict[str, Any], root: Path) -> tuple[bool, list[str]]:
|
|
700
|
+
"""Deny non-Core access to control.db and the Devflow project data dir; canonical
|
|
701
|
+
acceptance results are additionally readable by pure read tools (never writable)."""
|
|
702
|
+
cwd = Path(str(event.get("cwd") or root)).expanduser()
|
|
703
|
+
extra = _extra_control_store_globs(root)
|
|
704
|
+
allow_read = is_pure_read_tool(event)
|
|
705
|
+
blocked = [raw for raw in _raw_tool_paths(event) if path_is_control_store(raw, cwd, extra, allow_read)]
|
|
706
|
+
if blocked:
|
|
707
|
+
return True, sorted(dict.fromkeys(blocked))
|
|
708
|
+
command = command_text(event)
|
|
709
|
+
if command and command_touches_control_store(command, cwd):
|
|
710
|
+
return True, ["<shell command>"]
|
|
711
|
+
return False, []
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
# MCP authorization and audit data are user-local security state. This guard
|
|
715
|
+
# is deliberately narrower than DEVFLOW_HOME: only mcp.json and mcp-audit/
|
|
716
|
+
# are protected, and the decision is independent of soft-gate policy.
|
|
717
|
+
def path_is_mcp_protected(raw: str, cwd: Path) -> bool:
|
|
718
|
+
candidate = Path(_expand_shell_path(str(raw)))
|
|
719
|
+
if not candidate.is_absolute():
|
|
720
|
+
candidate = cwd / candidate
|
|
721
|
+
lexical = Path(os.path.normpath(str(candidate)))
|
|
722
|
+
resolved = Path(os.path.realpath(str(candidate)))
|
|
723
|
+
home = devflow_home()
|
|
724
|
+
config = home / "mcp.json"
|
|
725
|
+
audit = home / "mcp-audit"
|
|
726
|
+
for item in (lexical, resolved):
|
|
727
|
+
if item == config or _under(item, audit):
|
|
728
|
+
return True
|
|
729
|
+
return False
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _analyze_mcp_command(command: str, cwd: Path, depth: int = 0) -> bool:
|
|
733
|
+
tokens = _shell_tokens(command)
|
|
734
|
+
for segment in _segments(tokens):
|
|
735
|
+
index = 0
|
|
736
|
+
while index < len(segment) and (ASSIGNMENT_RE.match(segment[index]) or segment[index] in WRAPPER_PROGRAMS):
|
|
737
|
+
index += 1
|
|
738
|
+
if index >= len(segment):
|
|
739
|
+
continue
|
|
740
|
+
program = os.path.basename(segment[index]).lower()
|
|
741
|
+
operands = segment[index + 1:]
|
|
742
|
+
if program in SHELL_PROGRAMS and depth < 3:
|
|
743
|
+
for pos, operand in enumerate(operands):
|
|
744
|
+
if operand.startswith("-") and "c" in operand[1:] and pos + 1 < len(operands):
|
|
745
|
+
if _analyze_mcp_command(operands[pos + 1], cwd, depth + 1):
|
|
746
|
+
return True
|
|
747
|
+
ignored: set[int] = set()
|
|
748
|
+
if program in {"echo", "printf"}:
|
|
749
|
+
ignored.update(range(len(operands)))
|
|
750
|
+
elif program in {"rg", "grep", "ag", "ack"}:
|
|
751
|
+
seen_pattern = False
|
|
752
|
+
pos = 0
|
|
753
|
+
while pos < len(operands):
|
|
754
|
+
operand = operands[pos]
|
|
755
|
+
if operand in {"-e", "--regexp"} and pos + 1 < len(operands):
|
|
756
|
+
ignored.add(pos + 1)
|
|
757
|
+
pos += 2
|
|
758
|
+
continue
|
|
759
|
+
if operand.startswith("--regexp=") or (not operand.startswith("-") and not seen_pattern):
|
|
760
|
+
ignored.add(pos)
|
|
761
|
+
seen_pattern = True
|
|
762
|
+
pos += 1
|
|
763
|
+
elif program == "git":
|
|
764
|
+
for pos, operand in enumerate(operands):
|
|
765
|
+
if operand in {"-m", "--message"} and pos + 1 < len(operands):
|
|
766
|
+
ignored.add(pos + 1)
|
|
767
|
+
previous = ""
|
|
768
|
+
for pos, operand in enumerate(operands):
|
|
769
|
+
if previous in REDIRECT_OPERATORS and path_is_mcp_protected(operand, cwd):
|
|
770
|
+
return True
|
|
771
|
+
if pos in ignored:
|
|
772
|
+
previous = operand
|
|
773
|
+
continue
|
|
774
|
+
if operand in REDIRECT_OPERATORS or operand.startswith("-"):
|
|
775
|
+
previous = operand
|
|
776
|
+
continue
|
|
777
|
+
if path_is_mcp_protected(operand, cwd):
|
|
778
|
+
return True
|
|
779
|
+
previous = operand
|
|
780
|
+
return False
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def tool_touches_mcp_config(event: dict[str, Any], root: Path) -> tuple[bool, list[str]]:
|
|
784
|
+
cwd = Path(str(event.get("cwd") or root)).expanduser()
|
|
785
|
+
blocked = [raw for raw in _raw_tool_paths(event) if path_is_mcp_protected(raw, cwd)]
|
|
786
|
+
if blocked:
|
|
787
|
+
return True, sorted(dict.fromkeys(blocked))
|
|
788
|
+
command = command_text(event)
|
|
789
|
+
if command and _analyze_mcp_command(command, cwd):
|
|
790
|
+
return True, ["<shell command>"]
|
|
791
|
+
return False, []
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
# Run artifacts are user-local evidence, not agent-readable source. Keep this
|
|
795
|
+
# guard separate from the control-store guard: it is always on and its only
|
|
796
|
+
# read exception is the Core-produced result.json summary.
|
|
797
|
+
def _run_root(cwd: Path) -> Path:
|
|
798
|
+
# macOS commonly exposes /var as a symlink to /private/var; use the
|
|
799
|
+
# canonical cwd so lexical and realpath checks compare in one namespace.
|
|
800
|
+
return Path(os.path.realpath(str(cwd))) / ".devflow" / "runs"
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _run_relative(raw: str, cwd: Path, anchor: Path | None = None) -> tuple[Path, Path, Path]:
|
|
804
|
+
# The protected root is anchored at the project root, never at the current
|
|
805
|
+
# shell cwd, so `cd .devflow && cat runs/...` still resolves into it.
|
|
806
|
+
expanded = _expand_shell_path(raw)
|
|
807
|
+
cwd_text = str(cwd)
|
|
808
|
+
expanded = re.sub(r"\$\{PWD\}|\$PWD\b", cwd_text, expanded)
|
|
809
|
+
candidate = Path(expanded.replace("\\", "/"))
|
|
810
|
+
if not candidate.is_absolute():
|
|
811
|
+
candidate = cwd / candidate
|
|
812
|
+
lexical = Path(os.path.normpath(str(candidate)))
|
|
813
|
+
resolved = Path(os.path.realpath(str(candidate)))
|
|
814
|
+
return lexical, resolved, _run_root(anchor or cwd)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _under_run(path: Path, run_root: Path) -> bool:
|
|
818
|
+
return _under(path, run_root)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def path_is_run_artifact(raw: str, cwd: Path, tool: dict[str, Any] | str) -> bool:
|
|
822
|
+
"""Return whether a tool path enters .devflow/runs.
|
|
823
|
+
|
|
824
|
+
``result.json`` is readable only by pure-read tools. Glob may list the
|
|
825
|
+
runs directory itself; every other path, including result.json writes and
|
|
826
|
+
symlink aliases, remains protected.
|
|
827
|
+
"""
|
|
828
|
+
lexical, resolved, run_root = _run_relative(raw, cwd)
|
|
829
|
+
paths = (lexical, resolved)
|
|
830
|
+
tool_event = tool if isinstance(tool, dict) else {"tool_name": tool}
|
|
831
|
+
pure_read = is_pure_read_tool(tool_event)
|
|
832
|
+
name = tool_name(tool_event)
|
|
833
|
+
for item in paths:
|
|
834
|
+
if not _under_run(item, run_root):
|
|
835
|
+
continue
|
|
836
|
+
relative = item.relative_to(run_root).parts
|
|
837
|
+
if name == "glob" and len(relative) == 0:
|
|
838
|
+
continue
|
|
839
|
+
if pure_read and len(relative) == 3 and relative[-1] == "result.json":
|
|
840
|
+
continue
|
|
841
|
+
return True
|
|
842
|
+
return False
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
RUN_FILE_PROGRAMS = {
|
|
846
|
+
"cat", "cp", "mv", "rm", "tee", "touch", "truncate", "install", "rsync", "scp",
|
|
847
|
+
"tar", "unzip", "zip", "base64", "xxd", "strings", "od", "hexdump", "file", "stat",
|
|
848
|
+
"head", "tail", "less", "more", "sed", "awk", "nl", "bat", "rg", "grep", "find", "fd",
|
|
849
|
+
"ls", "tree", "du", "open", "sha256sum", "shasum", "md5sum", "md5", "cksum", "chmod",
|
|
850
|
+
"chown", "unlink", "shred", "dd", "gzip", "gunzip", "bzip2", "xz", "python", "python3",
|
|
851
|
+
"node", "bun", "deno", "ruby", "perl", "php", "lua",
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def _token_is_run_target(token: str, cwd: Path, anchor: Path | None = None) -> bool:
|
|
856
|
+
if not token or token in REDIRECT_OPERATORS or token.startswith("-"):
|
|
857
|
+
return False
|
|
858
|
+
lexical, resolved, run_root = _run_relative(token, cwd, anchor)
|
|
859
|
+
return _under_run(lexical, run_root) or _under_run(resolved, run_root)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _analyze_run_command(command: str, cwd: Path, depth: int = 0, anchor: Path | None = None) -> bool:
|
|
863
|
+
anchor = anchor or cwd
|
|
864
|
+
tokens = _shell_tokens(command)
|
|
865
|
+
current_cwd = cwd
|
|
866
|
+
for segment in _segments(tokens):
|
|
867
|
+
index = 0
|
|
868
|
+
while index < len(segment) and (ASSIGNMENT_RE.match(segment[index]) or segment[index] in WRAPPER_PROGRAMS):
|
|
869
|
+
index += 1
|
|
870
|
+
if index >= len(segment):
|
|
871
|
+
continue
|
|
872
|
+
program = os.path.basename(segment[index]).lower()
|
|
873
|
+
operands = segment[index + 1:]
|
|
874
|
+
if program in SHELL_PROGRAMS and depth < 3:
|
|
875
|
+
for pos, operand in enumerate(operands):
|
|
876
|
+
if operand.startswith("-") and "c" in operand[1:] and pos + 1 < len(operands):
|
|
877
|
+
if _analyze_run_command(operands[pos + 1], current_cwd, depth + 1, anchor):
|
|
878
|
+
return True
|
|
879
|
+
if program == "cd" and operands:
|
|
880
|
+
if _token_is_run_target(operands[-1], current_cwd, anchor):
|
|
881
|
+
return True
|
|
882
|
+
candidate = Path(_expand_shell_path(operands[-1]))
|
|
883
|
+
current_cwd = candidate if candidate.is_absolute() else current_cwd / candidate
|
|
884
|
+
current_cwd = Path(os.path.normpath(str(current_cwd)))
|
|
885
|
+
continue
|
|
886
|
+
# Any shell redirection is a real filesystem access, even when the
|
|
887
|
+
# command producing the bytes is otherwise harmless (e.g. echo).
|
|
888
|
+
previous = ""
|
|
889
|
+
for operand in operands:
|
|
890
|
+
if previous in REDIRECT_OPERATORS and _token_is_run_target(operand, current_cwd, anchor):
|
|
891
|
+
return True
|
|
892
|
+
previous = operand
|
|
893
|
+
# Inline interpreters can open arbitrary files. Reject the source
|
|
894
|
+
# text conservatively whenever it names the protected tree.
|
|
895
|
+
if program in {"python", "python3", "node", "bun", "deno", "ruby", "perl", "php"}:
|
|
896
|
+
for pos, operand in enumerate(operands):
|
|
897
|
+
if operand in {"-c", "-e", "--eval"} and pos + 1 < len(operands):
|
|
898
|
+
if ".devflow/runs" in operands[pos + 1]:
|
|
899
|
+
return True
|
|
900
|
+
if any(operand.startswith(prefix) for prefix in ("--eval=", "-e=")) and ".devflow/runs" in operand.split("=", 1)[1]:
|
|
901
|
+
return True
|
|
902
|
+
|
|
903
|
+
# Every non-option operand is a potential filesystem target, regardless
|
|
904
|
+
# of the executable. Only documented text positions are exempt.
|
|
905
|
+
ignored: set[int] = set()
|
|
906
|
+
if program in {"echo", "printf"}:
|
|
907
|
+
ignored.update(range(len(operands)))
|
|
908
|
+
elif program in {"rg", "grep", "ag", "ack"}:
|
|
909
|
+
seen_pattern = False
|
|
910
|
+
pos = 0
|
|
911
|
+
while pos < len(operands):
|
|
912
|
+
operand = operands[pos]
|
|
913
|
+
if operand in {"-e", "--regexp"} and pos + 1 < len(operands):
|
|
914
|
+
ignored.add(pos + 1)
|
|
915
|
+
pos += 2
|
|
916
|
+
continue
|
|
917
|
+
if operand.startswith("--regexp="):
|
|
918
|
+
ignored.add(pos)
|
|
919
|
+
elif not operand.startswith("-") and not seen_pattern:
|
|
920
|
+
ignored.add(pos)
|
|
921
|
+
seen_pattern = True
|
|
922
|
+
pos += 1
|
|
923
|
+
elif program == "git":
|
|
924
|
+
for pos, operand in enumerate(operands):
|
|
925
|
+
if operand in {"-m", "--message"} and pos + 1 < len(operands):
|
|
926
|
+
ignored.add(pos + 1)
|
|
927
|
+
elif operand.startswith("--message=") or operand.startswith("-m="):
|
|
928
|
+
ignored.add(pos)
|
|
929
|
+
|
|
930
|
+
for pos, operand in enumerate(operands):
|
|
931
|
+
if pos in ignored or operand in REDIRECT_OPERATORS:
|
|
932
|
+
continue
|
|
933
|
+
target = operand
|
|
934
|
+
if operand.startswith("--") and "=" in operand:
|
|
935
|
+
target = operand.split("=", 1)[1]
|
|
936
|
+
if not target or target.startswith("-"):
|
|
937
|
+
continue
|
|
938
|
+
if _token_is_run_target(target, current_cwd, anchor):
|
|
939
|
+
return True
|
|
940
|
+
return False
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def command_touches_run_artifacts(command: str, cwd: Path | None = None) -> bool:
|
|
944
|
+
"""Deny shell access to real run-artifact paths, not textual mentions."""
|
|
945
|
+
return _analyze_run_command(command, cwd or Path.cwd())
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def tool_touches_run_artifacts(event: dict[str, Any], root: Path) -> tuple[bool, list[str]]:
|
|
949
|
+
cwd = Path(str(event.get("cwd") or root)).expanduser()
|
|
950
|
+
blocked = [raw for raw in _raw_tool_paths(event) if path_is_run_artifact(raw, cwd, event)]
|
|
951
|
+
if blocked:
|
|
952
|
+
return True, sorted(dict.fromkeys(blocked))
|
|
953
|
+
command = command_text(event)
|
|
954
|
+
if command and command_touches_run_artifacts(command, cwd):
|
|
955
|
+
return True, ["<shell command>"]
|
|
956
|
+
return False, []
|
|
957
|
+
|
|
958
|
+
|
|
445
959
|
def evidence_observed(event: dict[str, Any], root: Path, artifacts: list[str]) -> list[str]:
|
|
446
960
|
name = tool_name(event)
|
|
447
961
|
if not (any(marker in name for marker in READ_TOOL_MARKERS) or is_command_tool(event)):
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""PreToolUse hook: block protected source access until indexes were observed."""
|
|
2
|
+
"""PreToolUse hook: deny control-store access and block protected source access until indexes were observed."""
|
|
3
3
|
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
@@ -15,13 +15,58 @@ from hook_common import (
|
|
|
15
15
|
read_event,
|
|
16
16
|
record_prechange_fingerprints,
|
|
17
17
|
required_evidence,
|
|
18
|
+
tool_touches_mcp_config,
|
|
18
19
|
tool_requests_protected_source,
|
|
20
|
+
tool_touches_control_store,
|
|
21
|
+
tool_touches_run_artifacts,
|
|
19
22
|
)
|
|
20
23
|
|
|
21
24
|
|
|
25
|
+
def deny(reason: str) -> int:
|
|
26
|
+
print(
|
|
27
|
+
json.dumps(
|
|
28
|
+
{
|
|
29
|
+
"hookSpecificOutput": {
|
|
30
|
+
"hookEventName": "PreToolUse",
|
|
31
|
+
"permissionDecision": "deny",
|
|
32
|
+
"permissionDecisionReason": reason,
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
ensure_ascii=False,
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
22
41
|
def main() -> int:
|
|
23
42
|
event = read_event()
|
|
24
43
|
root = project_root(event)
|
|
44
|
+
# Hard protection, evaluated before any soft/strict policy handling.
|
|
45
|
+
mcp_blocked, mcp_paths = tool_touches_mcp_config(event, root)
|
|
46
|
+
if mcp_blocked:
|
|
47
|
+
return deny(
|
|
48
|
+
"MCP_CONFIG_PROTECTED: $DEVFLOW_HOME/mcp.json and $DEVFLOW_HOME/mcp-audit/ are reserved for "
|
|
49
|
+
"Devflow MCP authorization and audit records; ordinary agent file and shell access is denied "
|
|
50
|
+
"regardless of DEVFLOW_SOFT_GATES. Blocked target: " + ", ".join(mcp_paths) + "."
|
|
51
|
+
)
|
|
52
|
+
control_blocked, control_paths = tool_touches_control_store(event, root)
|
|
53
|
+
if control_blocked:
|
|
54
|
+
return deny(
|
|
55
|
+
"CONTROL_STORE_PROTECTED: Devflow control.db, the Devflow data-home projects tree and writes to canonical "
|
|
56
|
+
"acceptance results are reserved for Core runtime tools; this misuse-prevention hook denies ordinary agent file and "
|
|
57
|
+
"shell access to them regardless of DEVFLOW_SOFT_GATES, but is not a same-user process sandbox. "
|
|
58
|
+
"Canonical acceptance-results*.json may be read with the Read/Grep file tools. "
|
|
59
|
+
f"Use devflow_approval(action=\"propose\"|\"list\") for approvals. Blocked target: {', '.join(control_paths)}."
|
|
60
|
+
)
|
|
61
|
+
run_blocked, run_paths = tool_touches_run_artifacts(event, root)
|
|
62
|
+
if run_blocked:
|
|
63
|
+
return deny(
|
|
64
|
+
"RUN_ARTIFACTS_PROTECTED: .devflow/runs/ contains trace, screenshots, runtime logs and other "
|
|
65
|
+
"user-local evidence that Agent tools may not read, write or access through bash. Trace and screenshots "
|
|
66
|
+
"are for the user to inspect locally with Playwright in a terminal; Agent failure analysis must read "
|
|
67
|
+
"the Core-produced result.json summary instead. Only pure-read access to a feature execution's "
|
|
68
|
+
"result.json is allowed. Blocked target: " + ", ".join(run_paths) + "."
|
|
69
|
+
)
|
|
25
70
|
policy = load_policy(root)
|
|
26
71
|
session_id = ensure_session(root, event)
|
|
27
72
|
required = required_evidence(root, policy)
|
|
@@ -34,24 +79,11 @@ def main() -> int:
|
|
|
34
79
|
missing = [path for path in required if path not in observed]
|
|
35
80
|
strict = str(policy.get("mode", "strict")).lower() == "strict"
|
|
36
81
|
if strict and required and missing and blocked:
|
|
37
|
-
|
|
82
|
+
return deny(
|
|
38
83
|
"INDEX_GATE_BLOCKED: protected source access requires observed index evidence. "
|
|
39
84
|
f"Read these files first, in order: {', '.join(missing)}. "
|
|
40
85
|
f"Blocked target: {', '.join(paths)}. The gate records successful tool reads, not assistant promises."
|
|
41
86
|
)
|
|
42
|
-
print(
|
|
43
|
-
json.dumps(
|
|
44
|
-
{
|
|
45
|
-
"hookSpecificOutput": {
|
|
46
|
-
"hookEventName": "PreToolUse",
|
|
47
|
-
"permissionDecision": "deny",
|
|
48
|
-
"permissionDecisionReason": reason,
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
ensure_ascii=False,
|
|
52
|
-
)
|
|
53
|
-
)
|
|
54
|
-
return 0
|
|
55
87
|
|
|
56
88
|
if is_mutation_tool(event):
|
|
57
89
|
record_prechange_fingerprints(root, session_id, direct_tool_paths(event, root))
|
|
@@ -60,4 +92,3 @@ def main() -> int:
|
|
|
60
92
|
|
|
61
93
|
if __name__ == "__main__":
|
|
62
94
|
raise SystemExit(main())
|
|
63
|
-
|