@naswerks/periscope 1.0.0 → 1.0.1
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 +6 -0
- package/README.md +2 -2
- package/contracts/wire-vectors/bulk_delivered.full.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.minimal.json +21 -0
- package/contracts/wire-vectors/bulk_delivered.without-stat-pair.json +22 -0
- package/contracts/wire-vectors/bulk_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/bulk_failed.unknown-reason.json +23 -0
- package/contracts/wire-vectors/bulk_request.full.json +21 -0
- package/contracts/wire-vectors/host_configure.set-and-remove.json +28 -0
- package/contracts/wire-vectors/host_configure_result.applied.json +34 -0
- package/contracts/wire-vectors/host_configure_result.refused.json +33 -0
- package/contracts/wire-vectors/link_ack.cursors.json +25 -0
- package/contracts/wire-vectors/link_ack.empty.json +16 -0
- package/contracts/wire-vectors/link_bye.cause.json +16 -0
- package/contracts/wire-vectors/link_hello.empty.json +33 -0
- package/contracts/wire-vectors/link_hello.full.json +44 -0
- package/contracts/wire-vectors/link_ping.nonce.json +16 -0
- package/contracts/wire-vectors/link_ping.unknown-payload-key.json +18 -0
- package/contracts/wire-vectors/link_pong.nonce.json +16 -0
- package/contracts/wire-vectors/link_welcome.empty.json +18 -0
- package/contracts/wire-vectors/link_welcome.full.json +25 -0
- package/contracts/wire-vectors/refused.empty.json +10 -0
- package/contracts/wire-vectors/refused.encode.empty-at.json +18 -0
- package/contracts/wire-vectors/refused.encode.empty-session-id.json +18 -0
- package/contracts/wire-vectors/refused.encode.missing-member.json +22 -0
- package/contracts/wire-vectors/refused.encode.seq-zero.json +18 -0
- package/contracts/wire-vectors/refused.encode.undeclared-configure-refusal.json +34 -0
- package/contracts/wire-vectors/refused.encode.undeclared-wire-refusal.json +27 -0
- package/contracts/wire-vectors/refused.missing-member.json +10 -0
- package/contracts/wire-vectors/refused.not-json.json +10 -0
- package/contracts/wire-vectors/refused.over-limit.json +10 -0
- package/contracts/wire-vectors/refused.seq-zero.json +10 -0
- package/contracts/wire-vectors/refused.unknown-frame.json +10 -0
- package/contracts/wire-vectors/refused.unknown-kind.json +10 -0
- package/contracts/wire-vectors/repository_list.root.json +19 -0
- package/contracts/wire-vectors/repository_list.subdirectory.json +19 -0
- package/contracts/wire-vectors/repository_list_result.entries.json +34 -0
- package/contracts/wire-vectors/repository_list_result.refused.json +24 -0
- package/contracts/wire-vectors/repository_read.head.json +20 -0
- package/contracts/wire-vectors/repository_read_result.refused.json +25 -0
- package/contracts/wire-vectors/repository_read_result.truncated.json +22 -0
- package/contracts/wire-vectors/session_cancel.bare.json +17 -0
- package/contracts/wire-vectors/session_configure.full.json +22 -0
- package/contracts/wire-vectors/session_configure.minimal.json +20 -0
- package/contracts/wire-vectors/session_delta.agent-message.json +27 -0
- package/contracts/wire-vectors/session_list.request.json +18 -0
- package/contracts/wire-vectors/session_list_result.empty.json +21 -0
- package/contracts/wire-vectors/session_list_result.full.json +36 -0
- package/contracts/wire-vectors/session_new.full.json +72 -0
- package/contracts/wire-vectors/session_new.minimal.json +22 -0
- package/contracts/wire-vectors/session_new.request-all-null.json +41 -0
- package/contracts/wire-vectors/session_prompt.empty.json +18 -0
- package/contracts/wire-vectors/session_prompt.multibyte.json +18 -0
- package/contracts/wire-vectors/session_prompt.text.json +18 -0
- package/contracts/wire-vectors/session_prompt.unknown-frame-key.json +20 -0
- package/contracts/wire-vectors/session_prompt.unknown-payload-key.json +20 -0
- package/contracts/wire-vectors/session_update.agent-message.json +26 -0
- package/contracts/wire-vectors/session_update.state-transition-with-activity.json +44 -0
- package/contracts/wire-vectors/session_update.state-transition.json +41 -0
- package/contracts/wire-vectors/session_update.wire-refusal.json +26 -0
- package/contracts/wire-vectors/transcript_failed.declared-reason.json +22 -0
- package/contracts/wire-vectors/transcript_list.from-start.json +19 -0
- package/contracts/wire-vectors/transcript_list.paged.json +19 -0
- package/contracts/wire-vectors/transcript_list_result.entry-without-cwd.json +30 -0
- package/contracts/wire-vectors/transcript_list_result.full.json +29 -0
- package/contracts/wire-vectors/transcript_list_result.last-page.json +29 -0
- package/contracts/wire-vectors/transcript_tail.any-user-entry.json +22 -0
- package/contracts/wire-vectors/transcript_tail.with-needle.json +22 -0
- package/contracts/wire-vectors/transcript_tail_result.absent.json +23 -0
- package/contracts/wire-vectors/transcript_tail_result.found.json +23 -0
- package/contracts/wire-vectors/workspace_list.from-start.json +19 -0
- package/contracts/wire-vectors/workspace_list_result.page.json +48 -0
- package/contracts/wire-vectors/workspace_list_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release.by-path-delete-branch.json +22 -0
- package/contracts/wire-vectors/workspace_release.request.json +22 -0
- package/contracts/wire-vectors/workspace_release_bulk.two-entries.json +32 -0
- package/contracts/wire-vectors/workspace_release_bulk_result.mixed.json +37 -0
- package/contracts/wire-vectors/workspace_release_result.partial.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.refused.json +26 -0
- package/contracts/wire-vectors/workspace_release_result.released.json +23 -0
- package/package.json +6 -1
- package/src/bin/command.ts +198 -0
- package/src/bin/config.ts +104 -0
- package/src/bin/login.ts +149 -0
- package/src/bin/main.ts +204 -0
- package/src/bin/pair.ts +246 -0
- package/src/bin/periscope.ts +3 -0
- package/src/bin/posture.ts +232 -0
- package/src/bin/reconfigure.ts +266 -0
- package/src/bin/serve.ts +615 -0
- package/src/bin/status.ts +61 -0
- package/src/bin/workspaces.ts +280 -0
- package/src/control/backoff.ts +40 -0
- package/src/control/codec.ts +557 -0
- package/src/control/credential.ts +33 -0
- package/src/control/frames.ts +1586 -0
- package/src/control/link-state.ts +67 -0
- package/src/control/link.ts +894 -0
- package/src/control/queue.ts +248 -0
- package/src/control/seq.ts +76 -0
- package/src/control/stream-routing.ts +216 -0
- package/src/control/stream.ts +149 -0
- package/src/core/async-queue.ts +63 -0
- package/src/core/failure.ts +61 -0
- package/src/core/index.ts +22 -0
- package/src/core/keyed-turns.ts +47 -0
- package/src/core/paths.ts +84 -0
- package/src/core/refusal.ts +430 -0
- package/src/core/result.ts +27 -0
- package/src/core/time.ts +34 -0
- package/src/core/vocab.ts +44 -0
- package/src/core/workspace-id.ts +11 -0
- package/src/gate/command.ts +617 -0
- package/src/gate/decision.ts +173 -0
- package/src/gate/escalate.ts +164 -0
- package/src/gate/gate.ts +635 -0
- package/src/gate/index.ts +53 -0
- package/src/gate/jail.ts +210 -0
- package/src/gate/local.ts +115 -0
- package/src/gate/outcome.ts +164 -0
- package/src/gate/shell.ts +295 -0
- package/src/host/agent-process.ts +694 -0
- package/src/host/bulk-post.ts +166 -0
- package/src/host/claude-transcripts.ts +404 -0
- package/src/host/config-file.ts +246 -0
- package/src/host/git-facts.ts +119 -0
- package/src/host/hooks.ts +102 -0
- package/src/host/host.ts +1901 -0
- package/src/host/index.ts +92 -0
- package/src/host/link-state-file.ts +79 -0
- package/src/host/loopback.ts +177 -0
- package/src/host/machine.ts +27 -0
- package/src/host/mcp-server.ts +66 -0
- package/src/host/package-facts.ts +25 -0
- package/src/host/paired-credential-store.ts +140 -0
- package/src/host/paths.ts +175 -0
- package/src/host/repository-read.ts +245 -0
- package/src/host/session-store.ts +133 -0
- package/src/host/sign-in.ts +352 -0
- package/src/host/telemetry.ts +105 -0
- package/src/host/token-cache.ts +214 -0
- package/src/host/transcript-fs.ts +110 -0
- package/src/host/wire-request.ts +285 -0
- package/src/host/workspace-fs.ts +90 -0
- package/src/host/workspace-trust.ts +92 -0
- package/src/identity/authorize.ts +127 -0
- package/src/identity/config.ts +227 -0
- package/src/identity/credential.ts +176 -0
- package/src/identity/device-code.ts +217 -0
- package/src/identity/index.ts +84 -0
- package/src/identity/mode.ts +161 -0
- package/src/identity/paired-credential.ts +89 -0
- package/src/identity/pkce.ts +113 -0
- package/src/identity/store.ts +98 -0
- package/src/identity/token.ts +203 -0
- package/src/index.ts +140 -0
- package/src/mcp/descriptor.ts +75 -0
- package/src/mcp/index.ts +23 -0
- package/src/mcp/schema.ts +214 -0
- package/src/mcp/server.ts +180 -0
- package/src/persistence/entry.ts +142 -0
- package/src/persistence/index.ts +47 -0
- package/src/persistence/key.ts +77 -0
- package/src/persistence/mirror.ts +118 -0
- package/src/persistence/receipt.ts +182 -0
- package/src/persistence/retention.ts +118 -0
- package/src/persistence/store.ts +183 -0
- package/src/persistence/transition-log.ts +176 -0
- package/src/protocol.ts +215 -0
- package/src/sessions/index.ts +27 -0
- package/src/sessions/registry.ts +392 -0
- package/src/sessions/session.ts +440 -0
- package/src/sessions/spawn-env.ts +318 -0
- package/src/state/coverage.ts +360 -0
- package/src/state/index.ts +58 -0
- package/src/state/machine.ts +359 -0
- package/src/state/model.ts +417 -0
- package/src/state/observer.ts +499 -0
- package/src/state/reporter.ts +60 -0
- package/src/state/store.ts +157 -0
- package/src/telemetry/index.ts +10 -0
- package/src/telemetry/usage.ts +117 -0
- package/src/workspace/git-worktree.ts +551 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/plain-dir.ts +132 -0
- package/src/workspace/provider.ts +159 -0
- package/src/workspace/worktree-porcelain.ts +118 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell backstop — two layers, and the second exists because the first was insufficient.
|
|
3
|
+
*
|
|
4
|
+
* A shell command cannot be proven safe by looking at a path, so this is the only control over what
|
|
5
|
+
* a shell tool call can reach outside the workspace. Both layers run; either one refusing is enough.
|
|
6
|
+
*
|
|
7
|
+
* Layer 1, the denylist. Named boundary operations — publishing, remote surgery, branch deletion,
|
|
8
|
+
* merging — matched over the whole scannable text with `[\s\S]`, so a newline cannot hide one
|
|
9
|
+
* inside a compound command.
|
|
10
|
+
*
|
|
11
|
+
* Layer 2, the git verb allowlist. This layer is not redundancy; it closes a real defect class.
|
|
12
|
+
* `git send-pack` — the plumbing that `git push` calls underneath — matches no denylist pattern,
|
|
13
|
+
* and a denylist-only classifier approved it as benign shell, which force-pushed a main branch
|
|
14
|
+
* under an automated actor. A denylist under-includes by construction: it can only refuse what
|
|
15
|
+
* somebody thought of. So a git invocation whose verb is not provably safe is refused without
|
|
16
|
+
* enumeration, which covers `send-pack`, `receive-pack`, aliases, and every verb git ships in a
|
|
17
|
+
* future release.
|
|
18
|
+
*
|
|
19
|
+
* The input is parsed, not raw — see `command.ts` for the defects a raw scan produces. Layer 1
|
|
20
|
+
* scans `ParsedCommand.scannable`, which is the raw command minus comments and provably-inert data
|
|
21
|
+
* payloads; layer 2 reads the parsed invocations. That narrows what is scanned and never what is
|
|
22
|
+
* denied.
|
|
23
|
+
*
|
|
24
|
+
* The rule that decides every ambiguous case: a false refusal costs one human click; a false allow
|
|
25
|
+
* costs the invariant. Every unknown shape in this file resolves toward refusing.
|
|
26
|
+
*/
|
|
27
|
+
import type { Refusal } from '../core/refusal.js';
|
|
28
|
+
import { refusal } from '../core/refusal.js';
|
|
29
|
+
import type { ParsedCommand } from './command.js';
|
|
30
|
+
import { parseCommand } from './command.js';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A named boundary rule and the pattern that recognises it.
|
|
34
|
+
*
|
|
35
|
+
* The name reaches the model verbatim in the refusal detail, because a refusal that does not say
|
|
36
|
+
* which rule fired leaves the reader to guess, and a degrade is a named outcome.
|
|
37
|
+
*/
|
|
38
|
+
interface BoundaryRule {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly pattern: RegExp;
|
|
41
|
+
readonly detail: string;
|
|
42
|
+
/**
|
|
43
|
+
* Evaluate the pattern per top-level command segment rather than over the whole string.
|
|
44
|
+
*
|
|
45
|
+
* The segmentation is the authority for what one command is; these patterns keep sole authority
|
|
46
|
+
* for which shapes of one command refuse. A whole-string scan lets a flag belonging to a piped
|
|
47
|
+
* command complete a pattern that began in an earlier one: `git branch -r --contains <sha> | tr
|
|
48
|
+
* -d ' '` refused as "branch deletion" because the `-d` came from `tr`. That is a read-only
|
|
49
|
+
* listing, and refusing it costs a human click for nothing.
|
|
50
|
+
*
|
|
51
|
+
* Narrows what is scanned, never what is denied. A match inside any single segment still
|
|
52
|
+
* refuses; only cross-segment stitches — which the segmentation rules to be two commands — are
|
|
53
|
+
* discarded, and layer 2 is untouched either way.
|
|
54
|
+
*/
|
|
55
|
+
readonly segmentScoped?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Separators that end one top-level command, mirroring `command.ts`'s own set.
|
|
60
|
+
*
|
|
61
|
+
* Quote spans are not tracked here on purpose: an unbalanced or quoted separator yields more
|
|
62
|
+
* segments, and more segments can only ever make a pattern harder to satisfy... which would be the
|
|
63
|
+
* wrong direction. So the split runs over `scannable`, whose inert payloads are already masked, and
|
|
64
|
+
* a segment that cannot be determined is scanned as part of a larger one — the refusing direction.
|
|
65
|
+
*/
|
|
66
|
+
const SEGMENT_SPLIT = /[|&;\n\r()`]+/;
|
|
67
|
+
|
|
68
|
+
/** Does `pattern` match inside any single top-level segment of `text`? */
|
|
69
|
+
function matchesInAnySegment(pattern: RegExp, text: string): boolean {
|
|
70
|
+
return text.split(SEGMENT_SPLIT).some((segment) => pattern.test(segment));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The denylist, in match order. `[\s\S]` rather than `.` throughout: a compound command spanning a
|
|
75
|
+
* newline must not be able to hide a boundary operation behind the line break.
|
|
76
|
+
*/
|
|
77
|
+
const BOUNDARY_RULES: readonly BoundaryRule[] = [
|
|
78
|
+
{
|
|
79
|
+
name: 'git-push',
|
|
80
|
+
// The lookbehind excises exactly one token pair: `stash push`. `git stash push -- <paths>` is the
|
|
81
|
+
// only canonical partial-stash form, and it parsed as a publish — so that operation was literally
|
|
82
|
+
// unperformable. A stash cannot publish anything, and layer 2 admits `stash` as a known-safe verb.
|
|
83
|
+
// The exclusion is whole-token (a bare `\b` matches inside a hyphenated token, so `my-stash push`
|
|
84
|
+
// had the suffix of `my-stash` read as the excluded token) and adjacency-strict, so
|
|
85
|
+
// `git stash push … ; git push origin main` still matches on the second occurrence.
|
|
86
|
+
pattern: /\bgit\b[\s\S]*?(?<!(?:^|[\s;&|(){}])stash\s{1,8})\bpush\b/i,
|
|
87
|
+
detail: 'publishing to a remote is a human decision, not an agent one',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'git-remote-surgery',
|
|
91
|
+
// The lookbehind keeps `git ls-remote` — a remote query — out of this rule, whole-token for the
|
|
92
|
+
// same reason as above (a bare substring exclusion lets any token ending in `ls-` suppress it).
|
|
93
|
+
//
|
|
94
|
+
// The read-only forms are exempt, and the exemption is exactly these shapes: `-v`,
|
|
95
|
+
// `--verbose`, `show`, and the bare list. Everything mutating still refuses — `add`, `set-url`,
|
|
96
|
+
// `set-head`, `set-branches`, `rename`, `remove`, `prune`, `update`. `get-url` is read-only and
|
|
97
|
+
// still refuses on purpose: an exemption set that grows by inference is how a boundary erodes.
|
|
98
|
+
pattern: /\bgit\b[\s\S]*?(?<!(?:^|[\s;&|(){}])ls-)\bremote\b(?!\s+(?:-v|--verbose|show)\b)(?!\s*$)/i,
|
|
99
|
+
detail: 'changing where this repository publishes is a human decision',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'git-branch-delete',
|
|
103
|
+
// Plain `git branch`, `--show-current` and `checkout -b` all flow; `git push --delete` is caught
|
|
104
|
+
// by the publish rule above.
|
|
105
|
+
//
|
|
106
|
+
// Segment-scoped. `[\s\S]*?` crosses pipes, so a read-only listing would borrow its `-d` from
|
|
107
|
+
// a downstream `tr -d ' '` and refuse as a deletion. The publish rule is deliberately not
|
|
108
|
+
// scoped: a push anywhere in a piped command is a push.
|
|
109
|
+
pattern: /\bgit\b[\s\S]*?\bbranch\b[\s\S]*?(\s-[dD]\b|--delete\b)/i,
|
|
110
|
+
detail: 'deleting a branch destroys history a human may still need',
|
|
111
|
+
segmentScoped: true,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: 'gh-pr-merge',
|
|
115
|
+
pattern: /\bgh\b[\s\S]*?\bpr\b[\s\S]*?\bmerge\b/i,
|
|
116
|
+
detail: 'merging is a human boundary',
|
|
117
|
+
},
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Git verbs that are known-safe: reads, local-only writes, and download-only network.
|
|
122
|
+
*
|
|
123
|
+
* The organising idea is publish. None of these can send anything to a remote, which is why
|
|
124
|
+
* `fetch`, `pull` and `ls-remote` sit here despite touching the network, and why `commit` does too —
|
|
125
|
+
* a local commit publishes nothing.
|
|
126
|
+
*
|
|
127
|
+
* Exact-case: git subcommands are lowercase, so a cased oddity is not proven safe and refuses.
|
|
128
|
+
*/
|
|
129
|
+
const ALLOWED_GIT_VERBS: ReadonlySet<string> = new Set([
|
|
130
|
+
// reads
|
|
131
|
+
'status',
|
|
132
|
+
'log',
|
|
133
|
+
'diff',
|
|
134
|
+
'show',
|
|
135
|
+
'rev-parse',
|
|
136
|
+
'describe',
|
|
137
|
+
'blame',
|
|
138
|
+
'grep',
|
|
139
|
+
'shortlog',
|
|
140
|
+
'reflog',
|
|
141
|
+
'ls-files',
|
|
142
|
+
'ls-tree',
|
|
143
|
+
'cat-file',
|
|
144
|
+
'merge-base',
|
|
145
|
+
'rev-list',
|
|
146
|
+
'check-ignore',
|
|
147
|
+
'check-attr',
|
|
148
|
+
// read-only terminal flags, surfaced as the verb when nothing else is: git prints and exits.
|
|
149
|
+
'--version',
|
|
150
|
+
'--help',
|
|
151
|
+
// local-only writes. `branch -d` and friends are already refused by the denylist above.
|
|
152
|
+
'add',
|
|
153
|
+
'commit',
|
|
154
|
+
'restore',
|
|
155
|
+
'checkout',
|
|
156
|
+
'switch',
|
|
157
|
+
'branch',
|
|
158
|
+
'stash',
|
|
159
|
+
'init',
|
|
160
|
+
'merge',
|
|
161
|
+
'rebase',
|
|
162
|
+
'cherry-pick',
|
|
163
|
+
'revert',
|
|
164
|
+
'reset',
|
|
165
|
+
'rm',
|
|
166
|
+
'mv',
|
|
167
|
+
'tag',
|
|
168
|
+
'clean',
|
|
169
|
+
'apply',
|
|
170
|
+
// `hash-object` sits with the local writes rather than the reads, and the placement is the point.
|
|
171
|
+
// Its common form only prints a sha, but `-w` writes a loose object — and this allowlist is
|
|
172
|
+
// verb-level, so admitting the verb admits `-w`. It qualifies on the same ground `init` does: an
|
|
173
|
+
// unreferenced loose object cannot publish anything.
|
|
174
|
+
'hash-object',
|
|
175
|
+
// download-only network — cannot publish.
|
|
176
|
+
'fetch',
|
|
177
|
+
'pull',
|
|
178
|
+
'ls-remote',
|
|
179
|
+
]);
|
|
180
|
+
|
|
181
|
+
/** `git config` flags that only read. Exact tokens, never prefixes. */
|
|
182
|
+
const CONFIG_READ_FLAGS: ReadonlySet<string> = new Set([
|
|
183
|
+
'--get',
|
|
184
|
+
'--get-all',
|
|
185
|
+
'--get-regexp',
|
|
186
|
+
'--get-urlmatch',
|
|
187
|
+
'--get-color',
|
|
188
|
+
'--get-colorbool',
|
|
189
|
+
'--list',
|
|
190
|
+
'-l',
|
|
191
|
+
]);
|
|
192
|
+
|
|
193
|
+
/** `git config` flags that mutate. If present, refuse, even beside a read flag. */
|
|
194
|
+
const CONFIG_WRITE_FLAGS: ReadonlySet<string> = new Set([
|
|
195
|
+
'--add',
|
|
196
|
+
'--unset',
|
|
197
|
+
'--unset-all',
|
|
198
|
+
'--replace-all',
|
|
199
|
+
'--set-all',
|
|
200
|
+
'--edit',
|
|
201
|
+
'-e',
|
|
202
|
+
'--rename-section',
|
|
203
|
+
'--remove-section',
|
|
204
|
+
'--default',
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
/** The `git remote` shapes that only print. The same three the denylist above exempts. */
|
|
208
|
+
const REMOTE_READ_FLAGS: ReadonlySet<string> = new Set(['-v', '--verbose']);
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `git config` is a read verb and a write verb wearing one name, so it cannot sit in the allowlist:
|
|
212
|
+
* a bare `git config user.email you@example` is a write and reads identically to a query without
|
|
213
|
+
* this rule. Only the provably-reading shapes are admitted.
|
|
214
|
+
*/
|
|
215
|
+
function isReadOnlyConfig(args: readonly string[]): boolean {
|
|
216
|
+
let sawRead = false;
|
|
217
|
+
for (const argument of args) {
|
|
218
|
+
if (CONFIG_WRITE_FLAGS.has(argument)) return false;
|
|
219
|
+
if (CONFIG_READ_FLAGS.has(argument)) sawRead = true;
|
|
220
|
+
}
|
|
221
|
+
return sawRead;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* `git remote`, same problem and same treatment. Admitted: the bare list, `-v`/`--verbose`, and
|
|
226
|
+
* `show <name>`. Anything else — a subcommand, an unknown flag — is not proven read-only and refuses.
|
|
227
|
+
*
|
|
228
|
+
* Both layers must agree before a remote command flows. The vocabulary is stated twice on purpose
|
|
229
|
+
* rather than shared through one looser predicate: two independent statements of the same three
|
|
230
|
+
* shapes cannot both be widened by accident.
|
|
231
|
+
*/
|
|
232
|
+
function isReadOnlyRemote(args: readonly string[]): boolean {
|
|
233
|
+
for (const argument of args) {
|
|
234
|
+
if (argument === 'show') return true;
|
|
235
|
+
if (REMOTE_READ_FLAGS.has(argument)) continue;
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isAllowedGitInvocation(verb: string, args: readonly string[]): boolean {
|
|
242
|
+
if (ALLOWED_GIT_VERBS.has(verb)) return true;
|
|
243
|
+
if (verb === 'config') return isReadOnlyConfig(args);
|
|
244
|
+
if (verb === 'remote') return isReadOnlyRemote(args);
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Classify a shell command. Returns the refusal that fired, or null when nothing did.
|
|
252
|
+
*
|
|
253
|
+
* `parsed` may be supplied by a caller that already parsed the command — the credential check does,
|
|
254
|
+
* and parsing twice would be work for nothing.
|
|
255
|
+
*/
|
|
256
|
+
export function classifyShellCommand(
|
|
257
|
+
command: string,
|
|
258
|
+
parsed: ParsedCommand = parseCommand(command),
|
|
259
|
+
): Refusal | null {
|
|
260
|
+
// Layer 1 — the denylist, over text the shell could actually execute.
|
|
261
|
+
for (const rule of BOUNDARY_RULES) {
|
|
262
|
+
const fired =
|
|
263
|
+
rule.segmentScoped === true
|
|
264
|
+
? matchesInAnySegment(rule.pattern, parsed.scannable)
|
|
265
|
+
: rule.pattern.test(parsed.scannable);
|
|
266
|
+
if (fired) {
|
|
267
|
+
return refusal('shell-boundary-command', `${rule.name}: ${rule.detail}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Layer 2 — the git verb allowlist. Non-git invocations are not this layer's business; shell
|
|
272
|
+
// commands as a whole are covered by the workspace jail, this file, and the audit trail together.
|
|
273
|
+
//
|
|
274
|
+
// It is deliberately git-only. Extending an allowlist posture to another program is a boundary
|
|
275
|
+
// decision that belongs to the embedder's policy (`ToolFamilies`, the local gate's options), not
|
|
276
|
+
// to this file.
|
|
277
|
+
for (const invocation of parsed.invocations) {
|
|
278
|
+
if (invocation.program !== 'git') continue;
|
|
279
|
+
if (invocation.verb === null) {
|
|
280
|
+
return refusal(
|
|
281
|
+
'shell-verb-unrecognised',
|
|
282
|
+
'a git invocation with no determinable verb cannot be shown to be safe, so it is refused',
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (!isAllowedGitInvocation(invocation.verb, invocation.args)) {
|
|
286
|
+
return refusal(
|
|
287
|
+
'shell-verb-unrecognised',
|
|
288
|
+
`git ${invocation.verb} is not a verb this host can prove is safe; the allowlist refuses ` +
|
|
289
|
+
'anything it cannot name, which is what covers the plumbing forms of publishing',
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return null;
|
|
295
|
+
}
|