dsh-desktop-kit 2.2.1 → 2.7.3
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 +86 -0
- package/README.md +114 -27
- package/client.js +3270 -32
- package/docs/AGENT-HARNESS.md +144 -0
- package/docs/ARCHITECTURE.md +38 -7
- package/docs/COMPATIBILITY.md +4 -2
- package/docs/DEVELOPMENT.md +56 -0
- package/docs/INSTALL.md +20 -11
- package/docs/LAPTOP-INSTALL.md +96 -0
- package/docs/ROADMAP-3.0.md +353 -0
- package/docs/ROADMAP.md +1 -1
- package/docs/SUBAGENTS.md +25 -0
- package/docs/TEST-0.2.2.md +5 -5
- package/docs/TEST-2.3.0.md +49 -0
- package/docs/TEST-2.7.2.md +46 -0
- package/docs/TEST-2.7.3.md +20 -0
- package/docs/TOKENS.md +2 -2
- package/docs/TROUBLESHOOTING.md +27 -0
- package/docs/UI-QA.md +28 -0
- package/docs/images/peak-toast-peak.png +0 -0
- package/docs/images/peak-toast-soon.png +0 -0
- package/docs/preview/peak-toast.html +155 -0
- package/index.js +18 -1
- package/package.json +26 -6
- package/plugins/dsh-agent-extensions/README.md +107 -0
- package/plugins/dsh-agent-extensions/client.js +157 -0
- package/plugins/dsh-agent-extensions/index.js +174 -0
- package/plugins/dsh-agent-extensions/lib/agents.js +76 -0
- package/plugins/dsh-agent-extensions/lib/api.js +53 -0
- package/plugins/dsh-agent-extensions/lib/checkpoints.js +103 -0
- package/plugins/dsh-agent-extensions/lib/commands.js +255 -0
- package/plugins/dsh-agent-extensions/lib/hooks.js +182 -0
- package/plugins/dsh-agent-extensions/lib/policy.js +84 -0
- package/plugins/dsh-agent-extensions/lib/project.js +144 -0
- package/plugins/dsh-agent-extensions/lib/scaffold.js +151 -0
- package/plugins/dsh-agent-extensions/package.json +20 -0
- package/plugins/dsh-agent-extensions/screenshots/agents-1440.png +0 -0
- package/plugins/dsh-agent-extensions/screenshots/agents-390.png +0 -0
- package/plugins/dsh-browser-qa/README.md +221 -0
- package/plugins/dsh-browser-qa/cordis.patch.yml +4 -0
- package/plugins/dsh-browser-qa/index.js +165 -0
- package/plugins/dsh-browser-qa/lib/cdp.js +224 -0
- package/plugins/dsh-browser-qa/lib/chrome.js +466 -0
- package/plugins/dsh-browser-qa/lib/pages.js +552 -0
- package/plugins/dsh-browser-qa/lib/qa.js +827 -0
- package/plugins/dsh-browser-qa/lib/ui-gate.js +256 -0
- package/plugins/dsh-browser-qa/package.json +31 -0
- package/plugins/dsh-checkpoints/README.md +22 -0
- package/plugins/dsh-checkpoints/cordis.patch.yml +3 -0
- package/plugins/dsh-checkpoints/index.js +80 -0
- package/plugins/dsh-checkpoints/lib/checkpoints.js +111 -0
- package/plugins/dsh-checkpoints/package.json +25 -0
- package/plugins/dsh-github-review/README.md +131 -0
- package/plugins/dsh-github-review/cordis.patch.yml +3 -0
- package/plugins/dsh-github-review/index.js +536 -0
- package/plugins/dsh-github-review/lib/gh.js +494 -0
- package/plugins/dsh-github-review/lib/normalize.js +662 -0
- package/plugins/dsh-github-review/package.json +28 -0
- package/plugins/dsh-guardrails/README.md +264 -0
- package/plugins/dsh-guardrails/cordis.patch.yml +5 -0
- package/plugins/dsh-guardrails/index.js +389 -0
- package/plugins/dsh-guardrails/lib/hooks.js +262 -0
- package/plugins/dsh-guardrails/lib/modes.js +77 -0
- package/plugins/dsh-guardrails/lib/policy.js +592 -0
- package/plugins/dsh-guardrails/lib/scan.js +451 -0
- package/plugins/dsh-guardrails/lib/secrets.js +274 -0
- package/plugins/dsh-guardrails/package.json +26 -0
- package/plugins/dsh-mcp-manager/README.md +78 -0
- package/plugins/dsh-mcp-manager/client.js +373 -0
- package/plugins/dsh-mcp-manager/index.js +159 -0
- package/plugins/dsh-mcp-manager/lib/api.js +250 -0
- package/plugins/dsh-mcp-manager/lib/client.js +219 -0
- package/plugins/dsh-mcp-manager/lib/config.js +217 -0
- package/plugins/dsh-mcp-manager/lib/manager.js +146 -0
- package/plugins/dsh-mcp-manager/package.json +18 -0
- package/plugins/dsh-mcp-manager/screenshots/modal-390.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-1440.png +0 -0
- package/plugins/dsh-mcp-manager/screenshots/settings-390.png +0 -0
- package/plugins/dsh-memory-bridge/README.md +161 -0
- package/plugins/dsh-memory-bridge/client.js +2264 -0
- package/plugins/dsh-memory-bridge/cordis.patch.yml +5 -0
- package/plugins/dsh-memory-bridge/docs/images/conflicts.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/hero.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-dark.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-light.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/settings-mobile.png +0 -0
- package/plugins/dsh-memory-bridge/docs/images/setup.png +0 -0
- package/plugins/dsh-memory-bridge/icon.svg +13 -0
- package/plugins/dsh-memory-bridge/index.js +378 -0
- package/plugins/dsh-memory-bridge/lib/bridge.js +636 -0
- package/plugins/dsh-memory-bridge/lib/github.js +473 -0
- package/plugins/dsh-memory-bridge/lib/merge.js +205 -0
- package/plugins/dsh-memory-bridge/lib/portable.js +314 -0
- package/plugins/dsh-memory-bridge/lib/state.js +565 -0
- package/plugins/dsh-memory-bridge/lib/sync.js +984 -0
- package/plugins/dsh-memory-bridge/locale/en.json +6 -0
- package/plugins/dsh-memory-bridge/locale/ru.json +6 -0
- package/plugins/dsh-memory-bridge/package.json +67 -0
- package/plugins/dsh-peak-guard/README.md +16 -3
- package/plugins/dsh-peak-guard/client.js +223 -8
- package/plugins/dsh-peak-guard/package.json +1 -1
- package/plugins/dsh-plan-mode/README.md +24 -0
- package/plugins/dsh-plan-mode/cordis.patch.yml +3 -0
- package/plugins/dsh-plan-mode/index.js +103 -0
- package/plugins/dsh-plan-mode/lib/plan.js +89 -0
- package/plugins/dsh-plan-mode/package.json +18 -0
- package/plugins/dsh-profile-transfer/client.js +277 -17
- package/plugins/dsh-profile-transfer/index.js +10 -0
- package/plugins/dsh-profile-transfer/package.json +3 -1
- package/plugins/dsh-profile-transfer/transfer.js +101 -2
- package/plugins/dsh-project-memory/index.js +92 -2
- package/plugins/dsh-project-memory/package.json +1 -1
- package/plugins/dsh-repo-map/README.md +26 -0
- package/plugins/dsh-repo-map/cordis.patch.yml +3 -0
- package/plugins/dsh-repo-map/index.js +99 -0
- package/plugins/dsh-repo-map/lib/repo-map.js +170 -0
- package/plugins/dsh-repo-map/package.json +25 -0
- package/plugins/dsh-skill-autopilot/index.js +135 -11
- package/skills/delivery-gate/SKILL.md +31 -0
- package/skills/git-and-ci/SKILL.md +5 -1
- package/skills/manifest.json +31 -6
- package/skills/requirements-and-planning/SKILL.md +3 -0
- package/skills/research-first/SKILL.md +35 -0
- package/skills/research-first/references/query-patterns.md +20 -0
- package/skills/subagent-playbook/SKILL.md +36 -0
- package/skills/subagent-playbook/references/prompts.md +39 -0
- package/skills/ui-craft/SKILL.md +43 -0
- package/skills/visual-qa/SKILL.md +29 -0
- package/skills/visual-qa/references/commands.md +32 -0
- package/skills-baseline.json +219 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# dsh-guardrails
|
|
2
|
+
|
|
3
|
+
Host guardrails for DSH: a small, inspectable policy layer plus a bounded
|
|
4
|
+
read-only security scan.
|
|
5
|
+
|
|
6
|
+
The plugin is local-only (Node built-ins, no network, no LLM) and has three
|
|
7
|
+
parts:
|
|
8
|
+
|
|
9
|
+
1. the model-facing `guardrails` tool — `status`, `check`, `policy`;
|
|
10
|
+
2. a `tools/pre-execute` hook — denies or asks before dangerous shell
|
|
11
|
+
commands, and denies writes/edits to protected paths requested through
|
|
12
|
+
file-mutating tools;
|
|
13
|
+
3. `fs/write-intent`, `fs/edit-intent`, `fs/delete-intent` hooks — refuse
|
|
14
|
+
filesystem mutations that target protected paths.
|
|
15
|
+
|
|
16
|
+
The package is a child plugin of the desktop kit (`export const inject =
|
|
17
|
+
["tools"]`): the kit entry mounts it via `ctx.plugin()` next to the other
|
|
18
|
+
bundled plugins. The kit passes no services to it beyond `tools`; the
|
|
19
|
+
`fs/*` hooks are event-only, which is why no `fs` injection is declared
|
|
20
|
+
(the shipped `dsh-fs-observation-policy` also injects nothing).
|
|
21
|
+
|
|
22
|
+
## Tool `guardrails`
|
|
23
|
+
|
|
24
|
+
Read-only. The canonical value is lossless JSON; the rendered text is a
|
|
25
|
+
bounded human summary.
|
|
26
|
+
|
|
27
|
+
| Action | What it returns |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `status` | Effective command rules (id, severity, description, default/policy source), protected paths, registered/missing hooks, `DSH_GUARDRAILS` kill-switch state, policy file state (loaded / invalid / defaults), limits, bypass list. |
|
|
30
|
+
| `check` | Bounded worktree scan: secrets, `.env` files, protected paths present in the tree, oversized files, dangerous commands in shell scripts, and `git diff --check`. Optional `root` (default: Session cwd), `maxFiles`, `maxFileSizeBytes`, `maxFindings` overrides (clamped). |
|
|
31
|
+
| `policy` | The effective JSON policy (defaults merged with the user file), plus the policy path, load error and warnings. |
|
|
32
|
+
|
|
33
|
+
Example calls:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{ "action": "status" }
|
|
37
|
+
{ "action": "check", "root": "C:\\work\\project", "maxFiles": 400 }
|
|
38
|
+
{ "action": "policy" }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`check` never executes a scanned file: the only subprocess is `git` with fixed
|
|
42
|
+
read-only arguments (`rev-parse`, `diff --check`). It is bounded by
|
|
43
|
+
`maxFiles` (default 800), `maxFileSizeBytes` (default 256 KiB),
|
|
44
|
+
`maxTotalBytes` (default 8 MiB), `maxDepth` (default 12), `maxFindings`
|
|
45
|
+
(default 200) and a git subprocess timeout (default 5 s). Symlinks are not
|
|
46
|
+
followed; binaries, oversized and skipped directories are counted in `stats`.
|
|
47
|
+
|
|
48
|
+
The scan is cooperative with cancellation: an aborted tool call returns a
|
|
49
|
+
partial report marked `aborted: true`.
|
|
50
|
+
|
|
51
|
+
## What is blocked
|
|
52
|
+
|
|
53
|
+
Default command rules (`deny` blocks the call, `ask` routes through the DSH
|
|
54
|
+
approval seam and degrades to a denial when no approval channel exists):
|
|
55
|
+
|
|
56
|
+
| Rule | Severity | Matches |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `rm-rf-root` | deny | `rm -rf /`, `rm -rf /*`, `sudo rm -fr ~`, `$HOME`, drive roots — but not `rm -rf ./build` |
|
|
59
|
+
| `pwsh-remove-root` | deny | `Remove-Item -Recurse -Force C:\`, `rd /s /q C:\`, `del /f /s /q C:\*` |
|
|
60
|
+
| `drop-database` | deny | `DROP DATABASE`, `DROP SCHEMA` |
|
|
61
|
+
| `disk-format` | deny | `mkfs*`, `wipefs`, `diskpart`, `dd … of=/dev/…`, `format C:`, `Format-Volume`, `Clear-Disk`, `Initialize-Disk` |
|
|
62
|
+
| `shell-overwrite-credentials` | deny | shell redirect/overwrite/truncation/removal of `.credentials*`, `id_rsa`, `id_ed25519`, `id_ecdsa`, `.pem`, `.p12`, `.pfx`, `.ssh/` |
|
|
63
|
+
| `shell-copy-credentials` | ask | shell copy/move of the same credential paths (`cp cert.pem /etc/ssl/`) |
|
|
64
|
+
| `git-push-force` | ask | `git push --force`, `git push -f`; `--force-with-lease` stays allowed |
|
|
65
|
+
| `git-reset-hard` | ask | `git reset --hard` |
|
|
66
|
+
| `truncate-table` | ask | `TRUNCATE TABLE …` / `TRUNCATE …` |
|
|
67
|
+
| `shell-write-dotenv` | ask | shell write/removal of `.env` (redirect, `Set-Content`, `rm`, `cp`, `tee`, …); `.env.example`/`.sample`/`.template`/`.dist` are allowed |
|
|
68
|
+
| `sed-inplace-protected` | ask | `sed -i` / `perl -pi` over `.env`, credentials or key files |
|
|
69
|
+
|
|
70
|
+
Rules are applied to the `command` argument of `bash`, `pwsh`, `powershell`,
|
|
71
|
+
`shell`, `sh`, `zsh`, `cmd` and `terminal` tools. The decision shape is the
|
|
72
|
+
event's own `PreToolDecision`: `{ kind: "deny", reason, info }` or
|
|
73
|
+
`{ kind: "ask", reason, displayReason: { en, ru } }`. Every other call
|
|
74
|
+
delegates with `next()` and is untouched.
|
|
75
|
+
|
|
76
|
+
Default protected paths (denied for `write`, `edit`, `apply_patch` arguments
|
|
77
|
+
and enforced authoritatively by the `fs/*-intent` hooks):
|
|
78
|
+
|
|
79
|
+
| Entry | Match |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `git-metadata` | any path segment equal to `.git` (but not `.gitignore`, `.github`) |
|
|
82
|
+
| `ssh-directory` | any path segment equal to `.ssh` |
|
|
83
|
+
| `env-file` | basename `.env` or `.env.*`, except `.env.example`, `.env.sample`, `.env.template`, `.env.dist` |
|
|
84
|
+
| `dsh-credentials` | `…/.dsh/.credentials.yaml` |
|
|
85
|
+
| `private-key-name` | `id_rsa`, `id_dsa`, `id_ecdsa`, `id_ed25519` |
|
|
86
|
+
| `private-key-ext` | `.pem`, `.key`, `.p12`, `.pfx`, `.jks` |
|
|
87
|
+
|
|
88
|
+
Reads are never blocked.
|
|
89
|
+
|
|
90
|
+
## Permission presets
|
|
91
|
+
|
|
92
|
+
DSH already owns the picker (`Read Only`, `Workspace Write`, `Full access`,
|
|
93
|
+
`Auto review`) and the approval card. Guardrails does **not** add a second
|
|
94
|
+
picker; it reads the effective `permissionPresets` mode and enforces hard
|
|
95
|
+
limits through the same `tools/pre-execute` hook:
|
|
96
|
+
|
|
97
|
+
| Mode | Files | Mutating shell | Outside workspace |
|
|
98
|
+
|---|---|---|---|
|
|
99
|
+
| `read-only` | deny write/edit/apply_patch/delete/move | deny | deny |
|
|
100
|
+
| `workspace-write` | allow inside cwd | allow | ask |
|
|
101
|
+
| `danger-full-access` | allow | allow | allow |
|
|
102
|
+
|
|
103
|
+
The kit's own policy rules still apply in every mode: `rm -rf /`, prod
|
|
104
|
+
database destruction, protected paths and secrets are denied even in Full
|
|
105
|
+
access. `ask` decisions use the shipped approval UI (`Allow once` / `Always
|
|
106
|
+
allow` / `Reject`).
|
|
107
|
+
|
|
108
|
+
## Policy file
|
|
109
|
+
|
|
110
|
+
Path: `<DSH_HOME>/guardrails.json` (default `~/.dsh/guardrails.json`; the
|
|
111
|
+
`DSH_HOME` environment variable is honoured). The file is read with an
|
|
112
|
+
mtime/size cache and is never loaded unbounded. Invalid JSON or an invalid
|
|
113
|
+
entry never takes the plugin down: the problem is reported by `status` /
|
|
114
|
+
`policy` and safe defaults stay in force.
|
|
115
|
+
|
|
116
|
+
Merge rules: objects merge recursively; `rules` and `protectedPaths` merge by
|
|
117
|
+
`id` (so one entry can be adjusted or disabled without restating the policy);
|
|
118
|
+
all other arrays replace the default value.
|
|
119
|
+
|
|
120
|
+
Example — disable the force-push ask, tighten `truncate`, add a path:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"version": 1,
|
|
125
|
+
"enabled": true,
|
|
126
|
+
"askBehavior": "ask",
|
|
127
|
+
"limits": { "maxFiles": 400, "gitTimeoutMs": 3000 },
|
|
128
|
+
"hooks": { "toolsPreExecute": true, "fsIntents": true },
|
|
129
|
+
"rules": [
|
|
130
|
+
{ "id": "git-push-force", "enabled": false },
|
|
131
|
+
{ "id": "truncate-table", "severity": "deny" },
|
|
132
|
+
{ "id": "team-danger", "severity": "deny", "pattern": "deploy\\s+--production", "description": "custom rule" }
|
|
133
|
+
],
|
|
134
|
+
"protectedPaths": [
|
|
135
|
+
{ "id": "team-secrets", "match": "segment", "pattern": "^\\.secrets$", "description": "team secret dir" }
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`askBehavior: "deny"` turns every `ask` rule into a hard denial (useful on
|
|
141
|
+
unattended machines). `enabled: false` disables the hooks and `check` while
|
|
142
|
+
keeping the tool available for inspection.
|
|
143
|
+
|
|
144
|
+
## Bypass (kill switch)
|
|
145
|
+
|
|
146
|
+
- `DSH_GUARDRAILS=off` — process environment; read on every hook and every
|
|
147
|
+
`check`, so no restart of the plugin is needed. `0`, `false` and `disabled`
|
|
148
|
+
are accepted as well.
|
|
149
|
+
- `guardrails.json` → `enabled: false`, or
|
|
150
|
+
`hooks: { toolsPreExecute: false }` / `hooks: { fsIntents: false }`.
|
|
151
|
+
- one rule or protected-path entry: `{ "id": "…", "enabled": false }`.
|
|
152
|
+
|
|
153
|
+
The tool itself is always readable, so `status` shows exactly what is active.
|
|
154
|
+
|
|
155
|
+
## Event contracts (verified against the live DSH Event inspect API)
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
tools/pre-execute (waterfall)
|
|
159
|
+
'tools/pre-execute'(this: Scoped<ToolsService>, exec: ToolExecution,
|
|
160
|
+
next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
|
161
|
+
PreToolDecision = { kind: 'allow' }
|
|
162
|
+
| { kind: 'deny'; reason: string; info?: ToolErrorInfo }
|
|
163
|
+
| { kind: 'cancel' }
|
|
164
|
+
| { kind: 'ask'; reason?: string; displayReason?: { en; [locale]: string } }
|
|
165
|
+
|
|
166
|
+
fs/write-intent (waterfall, single slot)
|
|
167
|
+
'fs/write-intent'(target: FsTarget, actor: object | undefined,
|
|
168
|
+
next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>)
|
|
169
|
+
: Promise<FsWriteIntent | undefined>
|
|
170
|
+
FsTarget = { targetKey: FsTargetKey; displayPath: string }
|
|
171
|
+
|
|
172
|
+
fs/edit-intent / fs/delete-intent (waterfall, single slot)
|
|
173
|
+
(target, actor, next) => Promise<{ version: FsVersion } | undefined>
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Used exactly:
|
|
177
|
+
|
|
178
|
+
- `tools/pre-execute` returns only `allow` (via `next()`), `deny` or `ask`
|
|
179
|
+
decisions. `next()` is always preferred for the allow path so later policy
|
|
180
|
+
listeners still run.
|
|
181
|
+
- `fs/write-intent` / `fs/edit-intent` / `fs/delete-intent` return `next()`
|
|
182
|
+
for paths that are not protected. **Ambiguity:** the `fs/*-intent`
|
|
183
|
+
contracts have no `deny` decision — their return value is a guard or
|
|
184
|
+
`undefined`, and the "first returned guard wins" slot has no refusal
|
|
185
|
+
variant. The shipped `dsh-fs-observation-policy` refuses by throwing a
|
|
186
|
+
coded `FsError` from the listener, and `dsh-tool-fs` dispatches the
|
|
187
|
+
waterfall so that a throw aborts the mutation. This plugin follows the
|
|
188
|
+
same mechanism with a plain `Error` (`.name =
|
|
189
|
+
"GuardrailsProtectedPathError"`, `.code = "GUARDRAILS_PROTECTED_PATH"`,
|
|
190
|
+
`.ruleId`) only when a path matches the policy and **delegates with
|
|
191
|
+
`next()` otherwise** — the documented conservative-allow fallback. If a
|
|
192
|
+
future DSH contract gains a real deny shape it should replace this throw.
|
|
193
|
+
- `tools/pre-execute` also performs a belt-and-braces check of `write` /
|
|
194
|
+
`edit` / `apply_patch` path arguments, because a first-wins `fs` policy
|
|
195
|
+
mounted before this plugin could own an intent slot (e.g. the shipped
|
|
196
|
+
observation policy). The `fs` hook remains the authoritative check for
|
|
197
|
+
resolved paths.
|
|
198
|
+
|
|
199
|
+
## Security properties
|
|
200
|
+
|
|
201
|
+
- Secret values never leave `lib/secrets.js` raw: findings carry a masked
|
|
202
|
+
placeholder (`AKIA…[redacted 20 chars]`), a line/column and a rule id.
|
|
203
|
+
`redactText()` is applied to command previews and git output. Tool results
|
|
204
|
+
and debug logs contain no raw token values.
|
|
205
|
+
- Hook logs contain only the event/tool name and the rule id — never command
|
|
206
|
+
text, target paths or secret values.
|
|
207
|
+
- Scanned files are never executed; the scanner performs no network I/O and
|
|
208
|
+
no writes. `git diff --check` is invoked with fixed arguments and a timeout.
|
|
209
|
+
- All matches are bounded by policy limits; the scanner skips symlinks and
|
|
210
|
+
binary content.
|
|
211
|
+
|
|
212
|
+
## Limitations
|
|
213
|
+
|
|
214
|
+
- These are heuristics, not a security boundary. Obfuscated commands
|
|
215
|
+
(base64, variable indirection, `python -c`, alternative shells), tools that
|
|
216
|
+
are not in the shell-name list, and PTC/`run_code` compositions can evade
|
|
217
|
+
the pre-execute rules. The plugin is a guardrail for a cooperative model,
|
|
218
|
+
not a sandbox — use the process sandbox/kernel isolation for untrusted code.
|
|
219
|
+
- `fs/*-intent` is a single-slot waterfall: another first-wins policy mounted
|
|
220
|
+
before this plugin can own the slot. The pre-execute path check is the
|
|
221
|
+
fallback for `write`/`edit`/`apply_patch`; a path protection that must be
|
|
222
|
+
absolute belongs in a provider-level fence.
|
|
223
|
+
- `ask` rules degrade to a denial when the DSH approval channel is missing or
|
|
224
|
+
the call has no agent.
|
|
225
|
+
- The command rules intentionally accept some false positives (`TRUNCATE` in
|
|
226
|
+
prose, `echo`/`copy` mentioning key files in scripts). Adjust or disable a
|
|
227
|
+
rule in `guardrails.json` when a project needs an exception. `.env.example`,
|
|
228
|
+
`.env.sample`, `.env.template` and `.env.dist` stay writable by default.
|
|
229
|
+
- `check` reports a protected path per file found in the tree, not per
|
|
230
|
+
directory; an empty `.ssh/` directory is not reported.
|
|
231
|
+
- Windows path matching is case-insensitive; POSIX matching uses the same
|
|
232
|
+
case-insensitive regexes (conservative over-matching, not under-matching).
|
|
233
|
+
- Scan limits are caps, not coverage guarantees: skipped/oversized/binary
|
|
234
|
+
counts are reported, and a truncated result is marked.
|
|
235
|
+
|
|
236
|
+
## Tests
|
|
237
|
+
|
|
238
|
+
```powershell
|
|
239
|
+
cd plugins/dsh-guardrails
|
|
240
|
+
node tools/guardrails-tests.mjs
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The suite has no kit dependency. It covers the pure policy functions (kill
|
|
244
|
+
switch, defaults, merge, clamping, protected paths, command verdicts),
|
|
245
|
+
secret detection/redaction, the bounded scan (including `git diff --check`
|
|
246
|
+
when `git` is available) and the exact event decision shapes with a fake
|
|
247
|
+
Cordis context. Expected result: `guardrails tests: OK (39 tests)`.
|
|
248
|
+
|
|
249
|
+
## Files
|
|
250
|
+
|
|
251
|
+
```text
|
|
252
|
+
dsh-guardrails/
|
|
253
|
+
├── package.json # package manifest + dsh bundle patch
|
|
254
|
+
├── cordis.patch.yml # Host row inserted by the kit
|
|
255
|
+
├── index.js # inject, guardrails tool, apply()
|
|
256
|
+
├── lib/
|
|
257
|
+
│ ├── hooks.js # tools/pre-execute + fs/*-intent listeners
|
|
258
|
+
│ ├── policy.js # defaults, JSON merge, matching, kill switch
|
|
259
|
+
│ ├── scan.js # bounded worktree scan + git diff --check
|
|
260
|
+
│ └── secrets.js # secret regexes, findings, redaction
|
|
261
|
+
├── tools/
|
|
262
|
+
│ └── guardrails-tests.mjs
|
|
263
|
+
└── README.md
|
|
264
|
+
```
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-guardrails — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Three capabilities, all local and read-only except for the mutations it
|
|
5
|
+
* refuses:
|
|
6
|
+
*
|
|
7
|
+
* - `guardrails` tool: `status` (effective rules, protected paths, hook
|
|
8
|
+
* state), `check` (bounded worktree scan for secrets, .env files,
|
|
9
|
+
* protected paths, oversized files, dangerous script commands and
|
|
10
|
+
* `git diff --check`) and `policy` (effective JSON policy).
|
|
11
|
+
* - `tools/pre-execute` hook: deny/ask dangerous shell commands and protect
|
|
12
|
+
* file-mutating tool arguments before dispatch.
|
|
13
|
+
* - `fs/write-intent`, `fs/edit-intent`, `fs/delete-intent` hooks: refuse
|
|
14
|
+
* writes, edits and deletions of protected paths at the filesystem seam.
|
|
15
|
+
*
|
|
16
|
+
* No network, no LLM, Node built-ins only. The documented bypass is the
|
|
17
|
+
* `DSH_GUARDRAILS=off` environment variable or the user policy file
|
|
18
|
+
* `<DSH_HOME>/guardrails.json`.
|
|
19
|
+
*/
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
|
|
23
|
+
import { createGuard, createHookState, registerHooks } from "./lib/hooks.js";
|
|
24
|
+
import { resolveMode } from "./lib/modes.js";
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_COMMAND_RULES,
|
|
27
|
+
DEFAULT_PROTECTED_PATHS,
|
|
28
|
+
KILL_SWITCH_ENV,
|
|
29
|
+
credentialsPath,
|
|
30
|
+
loadPolicy,
|
|
31
|
+
policyPath,
|
|
32
|
+
} from "./lib/policy.js";
|
|
33
|
+
import { scanProject } from "./lib/scan.js";
|
|
34
|
+
|
|
35
|
+
/** Tool and hook plugin depends on the tool registry only. */
|
|
36
|
+
export const inject = ["tools"];
|
|
37
|
+
|
|
38
|
+
const PLUGIN_NAME = "dsh-guardrails";
|
|
39
|
+
const PLUGIN_VERSION = "1.0.0";
|
|
40
|
+
const ACTIONS = ["status", "check", "policy"];
|
|
41
|
+
const DEFAULT_ACTION = "status";
|
|
42
|
+
const TEXT_LIMIT = 24_000;
|
|
43
|
+
|
|
44
|
+
const DEFAULT_RULE_IDS = new Set(DEFAULT_COMMAND_RULES.map((rule) => rule.id));
|
|
45
|
+
const DEFAULT_PROTECTED_IDS = new Set(DEFAULT_PROTECTED_PATHS.map((entry) => entry.id));
|
|
46
|
+
|
|
47
|
+
function messageOf(error) {
|
|
48
|
+
return error instanceof Error ? error.message : String(error);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function truncate(text, maximum = TEXT_LIMIT) {
|
|
52
|
+
const value = String(text ?? "");
|
|
53
|
+
return value.length <= maximum ? value : `${value.slice(0, Math.max(0, maximum - 1))}…`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function rootOf(args, exec) {
|
|
57
|
+
const explicit = typeof args?.root === "string" ? args.root.trim() : "";
|
|
58
|
+
const sessionCwd = exec?.agent?.session?.header?.cwd;
|
|
59
|
+
return path.resolve(explicit.length > 0 ? explicit : typeof sessionCwd === "string" && sessionCwd.length > 0 ? sessionCwd : process.cwd());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function finiteOverride(value) {
|
|
63
|
+
const number = Number(value);
|
|
64
|
+
return Number.isFinite(number) ? number : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* ------------------------------------------------------------------ */
|
|
68
|
+
/* status */
|
|
69
|
+
/* ------------------------------------------------------------------ */
|
|
70
|
+
|
|
71
|
+
function statusValue(loaded, hookState) {
|
|
72
|
+
const ruleList = loaded.policy.rules.map((rule) => ({
|
|
73
|
+
id: rule.id,
|
|
74
|
+
severity: rule.severity,
|
|
75
|
+
enabled: rule.enabled !== false,
|
|
76
|
+
description: rule.description,
|
|
77
|
+
source: DEFAULT_RULE_IDS.has(rule.id) ? "default" : "policy",
|
|
78
|
+
}));
|
|
79
|
+
const enabledRules = ruleList.filter((rule) => rule.enabled);
|
|
80
|
+
const protectedList = loaded.policy.protectedPaths.map((entry) => ({
|
|
81
|
+
id: entry.id,
|
|
82
|
+
enabled: entry.enabled !== false,
|
|
83
|
+
match: entry.match,
|
|
84
|
+
pattern: entry.pattern,
|
|
85
|
+
description: entry.description,
|
|
86
|
+
source: DEFAULT_PROTECTED_IDS.has(entry.id) ? "default" : "policy",
|
|
87
|
+
}));
|
|
88
|
+
const enabledProtected = protectedList.filter((entry) => entry.enabled);
|
|
89
|
+
const hooks = {
|
|
90
|
+
toolsPreExecute: hookState.toolsPreExecute,
|
|
91
|
+
fsWriteIntent: hookState.fsWriteIntent,
|
|
92
|
+
fsEditIntent: hookState.fsEditIntent,
|
|
93
|
+
fsDeleteIntent: hookState.fsDeleteIntent,
|
|
94
|
+
allRegistered:
|
|
95
|
+
hookState.toolsPreExecute && hookState.fsWriteIntent && hookState.fsEditIntent && hookState.fsDeleteIntent,
|
|
96
|
+
configured: { ...loaded.policy.hooks },
|
|
97
|
+
errors: hookState.errors,
|
|
98
|
+
};
|
|
99
|
+
const value = {
|
|
100
|
+
ok: true,
|
|
101
|
+
enabled: loaded.enabled,
|
|
102
|
+
plugin: { name: PLUGIN_NAME, version: PLUGIN_VERSION },
|
|
103
|
+
killSwitch: {
|
|
104
|
+
active: loaded.killSwitch,
|
|
105
|
+
variable: KILL_SWITCH_ENV,
|
|
106
|
+
acceptedValues: ["off", "0", "false", "disabled"],
|
|
107
|
+
},
|
|
108
|
+
policy: {
|
|
109
|
+
path: loaded.path,
|
|
110
|
+
exists: loaded.exists,
|
|
111
|
+
loadedFromFile: loaded.loadedFromFile,
|
|
112
|
+
version: loaded.policy.version,
|
|
113
|
+
error: loaded.error,
|
|
114
|
+
warnings: loaded.warnings,
|
|
115
|
+
askBehavior: loaded.policy.askBehavior,
|
|
116
|
+
},
|
|
117
|
+
hooks,
|
|
118
|
+
rules: {
|
|
119
|
+
total: ruleList.length,
|
|
120
|
+
enabled: enabledRules.length,
|
|
121
|
+
list: enabledRules,
|
|
122
|
+
disabled: ruleList.filter((rule) => !rule.enabled).map((rule) => rule.id),
|
|
123
|
+
},
|
|
124
|
+
protectedPaths: {
|
|
125
|
+
total: protectedList.length,
|
|
126
|
+
enabled: enabledProtected.length,
|
|
127
|
+
list: enabledProtected,
|
|
128
|
+
disabled: protectedList.filter((entry) => !entry.enabled).map((entry) => entry.id),
|
|
129
|
+
},
|
|
130
|
+
credentialsPath: credentialsPath(process.env),
|
|
131
|
+
limits: loaded.policy.limits,
|
|
132
|
+
bypass: [
|
|
133
|
+
`${KILL_SWITCH_ENV}=off (process environment)`,
|
|
134
|
+
"policy.enabled=false or policy.hooks.{toolsPreExecute,fsIntents}=false",
|
|
135
|
+
"a rule or protected path entry with enabled=false",
|
|
136
|
+
],
|
|
137
|
+
text: "",
|
|
138
|
+
};
|
|
139
|
+
value.text = renderStatus(value);
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function renderStatus(value) {
|
|
144
|
+
const lines = [];
|
|
145
|
+
const policyState = value.policy.loadedFromFile
|
|
146
|
+
? `loaded from ${value.policy.path}`
|
|
147
|
+
: value.policy.exists
|
|
148
|
+
? `${value.policy.path} is invalid, defaults in force`
|
|
149
|
+
: `defaults (no ${value.policy.path})`;
|
|
150
|
+
lines.push(`${PLUGIN_NAME} ${PLUGIN_VERSION} — ${value.enabled ? "ENABLED" : "DISABLED"} (${policyState})`);
|
|
151
|
+
if (value.policy.error) lines.push(`Policy error: ${value.policy.error}`);
|
|
152
|
+
for (const warning of value.policy.warnings.slice(0, 10)) lines.push(`Policy warning: ${truncate(warning, 300)}`);
|
|
153
|
+
lines.push(
|
|
154
|
+
`Kill switch: ${value.killSwitch.variable}=off disables every hook (currently ${value.killSwitch.active ? "ACTIVE" : "inactive"})`,
|
|
155
|
+
);
|
|
156
|
+
lines.push(
|
|
157
|
+
`Hooks: tools/pre-execute=${value.hooks.toolsPreExecute ? "on" : "off"} · fs/write-intent=${value.hooks.fsWriteIntent ? "on" : "off"} · fs/edit-intent=${value.hooks.fsEditIntent ? "on" : "off"} · fs/delete-intent=${value.hooks.fsDeleteIntent ? "on" : "off"}`,
|
|
158
|
+
);
|
|
159
|
+
if (value.hooks.errors.length > 0) {
|
|
160
|
+
for (const error of value.hooks.errors.slice(0, 5)) lines.push(`Hook error: ${error.event}: ${truncate(error.message, 200)}`);
|
|
161
|
+
}
|
|
162
|
+
const denyCount = value.rules.list.filter((rule) => rule.severity === "deny").length;
|
|
163
|
+
const askCount = value.rules.list.filter((rule) => rule.severity === "ask").length;
|
|
164
|
+
lines.push(`Command rules: ${value.rules.enabled}/${value.rules.total} enabled (deny ${denyCount}, ask ${askCount})`);
|
|
165
|
+
for (const rule of value.rules.list) lines.push(`- ${rule.severity.toUpperCase().padEnd(4)} ${rule.id} — ${truncate(rule.description, 160)}`);
|
|
166
|
+
if (value.rules.disabled.length > 0) lines.push(`Disabled rules: ${value.rules.disabled.join(", ")}`);
|
|
167
|
+
lines.push(`Protected paths: ${value.protectedPaths.enabled}/${value.protectedPaths.total} enabled`);
|
|
168
|
+
for (const entry of value.protectedPaths.list) {
|
|
169
|
+
lines.push(`- ${entry.id} (${entry.match} /${truncate(entry.pattern, 120)}/) — ${truncate(entry.description, 120)}`);
|
|
170
|
+
}
|
|
171
|
+
if (value.protectedPaths.disabled.length > 0) {
|
|
172
|
+
lines.push(`Disabled protected paths: ${value.protectedPaths.disabled.join(", ")}`);
|
|
173
|
+
}
|
|
174
|
+
lines.push(`DSH credentials file: ${value.credentialsPath}`);
|
|
175
|
+
lines.push(
|
|
176
|
+
`Limits: maxFiles=${value.limits.maxFiles}, maxFileSizeBytes=${value.limits.maxFileSizeBytes}, maxTotalBytes=${value.limits.maxTotalBytes}, maxFindings=${value.limits.maxFindings}, maxDepth=${value.limits.maxDepth}`,
|
|
177
|
+
);
|
|
178
|
+
lines.push("Bypass: DSH_GUARDRAILS=off · policy.enabled=false · policy hooks off · individual rule disabled");
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ------------------------------------------------------------------ */
|
|
183
|
+
/* policy */
|
|
184
|
+
/* ------------------------------------------------------------------ */
|
|
185
|
+
|
|
186
|
+
function policyValue(loaded) {
|
|
187
|
+
const value = {
|
|
188
|
+
ok: true,
|
|
189
|
+
path: loaded.path,
|
|
190
|
+
exists: loaded.exists,
|
|
191
|
+
loadedFromFile: loaded.loadedFromFile,
|
|
192
|
+
error: loaded.error,
|
|
193
|
+
warnings: loaded.warnings,
|
|
194
|
+
killSwitch: loaded.killSwitch,
|
|
195
|
+
enabled: loaded.enabled,
|
|
196
|
+
policy: loaded.policy,
|
|
197
|
+
text: "",
|
|
198
|
+
};
|
|
199
|
+
value.text = truncate(JSON.stringify({
|
|
200
|
+
path: value.path,
|
|
201
|
+
loadedFromFile: value.loadedFromFile,
|
|
202
|
+
error: value.error,
|
|
203
|
+
warnings: value.warnings,
|
|
204
|
+
killSwitch: value.killSwitch,
|
|
205
|
+
enabled: value.enabled,
|
|
206
|
+
policy: value.policy,
|
|
207
|
+
}, null, 2));
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ------------------------------------------------------------------ */
|
|
212
|
+
/* check */
|
|
213
|
+
/* ------------------------------------------------------------------ */
|
|
214
|
+
|
|
215
|
+
async function checkValue(loaded, args, exec, logger) {
|
|
216
|
+
const root = rootOf(args, exec);
|
|
217
|
+
if (!loaded.enabled) {
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
enabled: false,
|
|
221
|
+
root,
|
|
222
|
+
skipped: true,
|
|
223
|
+
reason: loaded.killSwitch
|
|
224
|
+
? `kill switch ${KILL_SWITCH_ENV} is active`
|
|
225
|
+
: `guardrails policy is disabled (${loaded.path})`,
|
|
226
|
+
findings: [],
|
|
227
|
+
text: "Guardrails are disabled; nothing was scanned. Re-enable by unsetting DSH_GUARDRAILS or setting policy.enabled=true.",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
let rootStat;
|
|
231
|
+
try {
|
|
232
|
+
rootStat = fs.statSync(root);
|
|
233
|
+
} catch {
|
|
234
|
+
return { ok: false, enabled: true, root, error: `root does not exist: ${root}`, findings: [], text: `guardrails check: root does not exist: ${root}` };
|
|
235
|
+
}
|
|
236
|
+
if (!rootStat.isDirectory()) {
|
|
237
|
+
return { ok: false, enabled: true, root, error: `root is not a directory: ${root}`, findings: [], text: `guardrails check: not a directory: ${root}` };
|
|
238
|
+
}
|
|
239
|
+
const overrides = {};
|
|
240
|
+
const maxFiles = finiteOverride(args?.maxFiles);
|
|
241
|
+
if (maxFiles !== undefined) overrides.maxFiles = maxFiles;
|
|
242
|
+
const maxFileSizeBytes = finiteOverride(args?.maxFileSizeBytes);
|
|
243
|
+
if (maxFileSizeBytes !== undefined) overrides.maxFileSizeBytes = maxFileSizeBytes;
|
|
244
|
+
const maxFindings = finiteOverride(args?.maxFindings);
|
|
245
|
+
if (maxFindings !== undefined) overrides.maxFindings = maxFindings;
|
|
246
|
+
const report = await scanProject({ root, policy: loaded.policy, env: process.env, signal: exec?.signal, overrides, logger });
|
|
247
|
+
const value = {
|
|
248
|
+
enabled: true,
|
|
249
|
+
policy: {
|
|
250
|
+
path: loaded.path,
|
|
251
|
+
loadedFromFile: loaded.loadedFromFile,
|
|
252
|
+
error: loaded.error,
|
|
253
|
+
warnings: loaded.warnings,
|
|
254
|
+
},
|
|
255
|
+
...report,
|
|
256
|
+
text: "",
|
|
257
|
+
};
|
|
258
|
+
value.text = renderScanReport(value);
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function severityCounts(findings) {
|
|
263
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
264
|
+
for (const finding of findings) {
|
|
265
|
+
if (Object.hasOwn(counts, finding.severity)) counts[finding.severity] += 1;
|
|
266
|
+
}
|
|
267
|
+
return counts;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function renderScanReport(value) {
|
|
271
|
+
if (value.ok === false) return `guardrails check: ${value.error ?? "scan failed"}`;
|
|
272
|
+
const stats = value.stats;
|
|
273
|
+
const lines = [];
|
|
274
|
+
lines.push(`Guardrails scan (read-only, no network): ${value.root}`);
|
|
275
|
+
lines.push(
|
|
276
|
+
`Files: scanned ${stats.filesScanned}, considered ${stats.filesConsidered}, bytes ${stats.bytesRead}, directories ${stats.directoriesVisited}`,
|
|
277
|
+
);
|
|
278
|
+
lines.push(
|
|
279
|
+
`Skipped: binary ${stats.skipped.binary}, oversized ${stats.skipped.oversized}, symlinks ${stats.skipped.symlinks}, depth-limit ${stats.skipped.depthLimit}, unreadable ${stats.skipped.unreadable}`,
|
|
280
|
+
);
|
|
281
|
+
const limitsHit = Object.entries(stats.limitsHit).filter(([, hit]) => hit === true).map(([key]) => key);
|
|
282
|
+
if (limitsHit.length > 0) lines.push(`Limits hit: ${limitsHit.join(", ")} (results are partial)`);
|
|
283
|
+
const findings = value.findings ?? [];
|
|
284
|
+
if (findings.length === 0) {
|
|
285
|
+
lines.push("Findings: none");
|
|
286
|
+
} else {
|
|
287
|
+
const counts = severityCounts(findings);
|
|
288
|
+
lines.push(
|
|
289
|
+
`Findings: ${findings.length}${value.findingsTruncated ? " (truncated)" : ""} — critical ${counts.critical}, high ${counts.high}, medium ${counts.medium}, low ${counts.low}`,
|
|
290
|
+
);
|
|
291
|
+
for (const finding of findings.slice(0, 40)) {
|
|
292
|
+
const location = finding.line !== undefined ? `${finding.path}:${finding.line}` : String(finding.path ?? "?");
|
|
293
|
+
const masked = typeof finding.masked === "string" ? ` [masked: ${finding.masked}]` : "";
|
|
294
|
+
lines.push(`- ${String(finding.severity).toUpperCase()} ${finding.kind}/${finding.rule} — ${location} — ${truncate(finding.message, 200)}${masked}`);
|
|
295
|
+
}
|
|
296
|
+
if (findings.length > 40) lines.push(`… ${findings.length - 40} more finding(s) are in the JSON value`);
|
|
297
|
+
}
|
|
298
|
+
if (value.gitDiffCheck != null) {
|
|
299
|
+
lines.push(
|
|
300
|
+
`git diff --check: ${value.gitDiffCheck.available ? value.gitDiffCheck.message : `unavailable (${value.gitDiffCheck.message})`}`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (value.aborted) lines.push("Scan aborted by cancellation; partial results only.");
|
|
304
|
+
lines.push("Scanned files are never executed; secret values are redacted in this result.");
|
|
305
|
+
return lines.join("\n");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* ------------------------------------------------------------------ */
|
|
309
|
+
/* Plugin entry */
|
|
310
|
+
/* ------------------------------------------------------------------ */
|
|
311
|
+
|
|
312
|
+
export function apply(ctx) {
|
|
313
|
+
const env = process.env;
|
|
314
|
+
const logger = ctx?.logger;
|
|
315
|
+
const hookState = createHookState();
|
|
316
|
+
const guard = createGuard({ env, logger, modeFor: (agent) => resolveMode(ctx, agent) });
|
|
317
|
+
try {
|
|
318
|
+
registerHooks(ctx, guard, hookState);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
hookState.errors.push({ event: "registration", message: messageOf(error) });
|
|
321
|
+
logger?.warn?.(`[guardrails] hook registration failed: ${messageOf(error)}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const tool = {
|
|
325
|
+
name: "guardrails",
|
|
326
|
+
description:
|
|
327
|
+
"Read-only guardrails control and security scan. `status` shows effective command rules, protected paths, hook registration and the DSH_GUARDRAILS kill switch. `check` scans a project or worktree with strict limits (no network) for secrets, .env files, protected paths, oversized files, dangerous commands in shell scripts and `git diff --check`; token values are redacted. `policy` prints the effective JSON policy. Scanning never executes files and never writes.",
|
|
328
|
+
parameters: {
|
|
329
|
+
type: "object",
|
|
330
|
+
additionalProperties: false,
|
|
331
|
+
properties: {
|
|
332
|
+
action: {
|
|
333
|
+
type: "string",
|
|
334
|
+
enum: ACTIONS,
|
|
335
|
+
description: "status (rules, protected paths, hooks) | check (bounded read-only scan) | policy (effective JSON policy).",
|
|
336
|
+
},
|
|
337
|
+
root: {
|
|
338
|
+
type: "string",
|
|
339
|
+
description: "Absolute project/worktree root for check; defaults to the current Session cwd.",
|
|
340
|
+
},
|
|
341
|
+
maxFiles: {
|
|
342
|
+
type: "integer",
|
|
343
|
+
description: "Override the check file cap (1..5000, default from policy 800).",
|
|
344
|
+
},
|
|
345
|
+
maxFileSizeBytes: {
|
|
346
|
+
type: "integer",
|
|
347
|
+
description: "Override the per-file byte cap for check (256..16777216, default 262144). Larger files are reported, not read.",
|
|
348
|
+
},
|
|
349
|
+
maxFindings: {
|
|
350
|
+
type: "integer",
|
|
351
|
+
description: "Override the findings cap for check (1..2000, default 200).",
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
required: ["action"],
|
|
355
|
+
},
|
|
356
|
+
timeoutMs: 30_000,
|
|
357
|
+
output: {
|
|
358
|
+
schema: { type: "object", additionalProperties: true },
|
|
359
|
+
render(_args, value) {
|
|
360
|
+
const text = typeof value?.text === "string" ? value.text : JSON.stringify(value);
|
|
361
|
+
return [{ type: "text", text: truncate(text) }];
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
async execute(args, exec) {
|
|
365
|
+
try {
|
|
366
|
+
const action = typeof args?.action === "string" ? args.action : DEFAULT_ACTION;
|
|
367
|
+
const loaded = loadPolicy({ env });
|
|
368
|
+
if (action === "policy") return policyValue(loaded);
|
|
369
|
+
if (action === "status") return statusValue(loaded, hookState);
|
|
370
|
+
if (action === "check") return await checkValue(loaded, args, exec, logger);
|
|
371
|
+
return {
|
|
372
|
+
ok: false,
|
|
373
|
+
enabled: loaded.enabled,
|
|
374
|
+
error: `unknown action: ${action}`,
|
|
375
|
+
text: `guardrails: unknown action "${action}". Available actions: ${ACTIONS.join(", ")}.`,
|
|
376
|
+
};
|
|
377
|
+
} catch (error) {
|
|
378
|
+
return {
|
|
379
|
+
ok: false,
|
|
380
|
+
error: messageOf(error),
|
|
381
|
+
text: `guardrails: ${messageOf(error)}`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
ctx.tools.register(tool);
|
|
388
|
+
logger?.info?.(`[guardrails] tool ready (policy: ${policyPath(env)})`);
|
|
389
|
+
}
|